Merge branch 'develop' into edit-slashcommands

This commit is contained in:
Travis Ralston 2021-05-10 20:09:33 -06:00
commit 12657f0fbc
336 changed files with 11521 additions and 4060 deletions

View file

@ -140,7 +140,12 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
public componentDidUpdate(prevProps: IProps) {
if (this.props.placeholder !== prevProps.placeholder && this.props.placeholder) {
// We need to re-check the placeholder when the enabled state changes because it causes the
// placeholder element to remount, which gets rid of the `::before` class. Re-evaluating the
// placeholder means we get a proper `::before` with the placeholder.
const enabledChange = this.props.disabled !== prevProps.disabled;
const placeholderChanged = this.props.placeholder !== prevProps.placeholder;
if (this.props.placeholder && (placeholderChanged || enabledChange)) {
const {isEmpty} = this.props.model;
if (isEmpty) {
this.showPlaceholder();
@ -670,8 +675,6 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
});
const classes = classNames("mx_BasicMessageComposer_input", {
"mx_BasicMessageComposer_input_shouldShowPillAvatar": this.state.showPillAvatar,
// TODO: @@ TravisR: This doesn't work properly. The composer resets in a strange way.
"mx_BasicMessageComposer_input_disabled": this.props.disabled,
});

View file

@ -176,8 +176,11 @@ class EntityTile extends React.Component {
// The wrapping div is required to make the magic mouse listener work, for some reason.
return (
<div ref={(c) => this.container = c} >
<AccessibleButton className={classNames(mainClassNames)} title={this.props.title}
onClick={this.props.onClick}>
<AccessibleButton
className={classNames(mainClassNames)}
title={this.props.title}
onClick={this.props.onClick}
>
<div className="mx_EntityTile_avatar">
{ av }
{ e2eIcon }

View file

@ -1,6 +1,6 @@
/*
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 - 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,11 +15,13 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import React from 'react';
import classNames from "classnames";
import {EventType} from "matrix-js-sdk/src/@types/event";
import {EventStatus} from 'matrix-js-sdk/src/models/event';
import { EventType } from "matrix-js-sdk/src/@types/event";
import { EventStatus, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Relations } from "matrix-js-sdk/src/models/relations";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import ReplyThread from "../elements/ReplyThread";
import { _t } from '../../../languageHandler';
@ -27,7 +29,7 @@ import * as TextForEvent from "../../../TextForEvent";
import * as sdk from "../../../index";
import dis from '../../../dispatcher/dispatcher';
import SettingsStore from "../../../settings/SettingsStore";
import {Layout, LayoutPropType} from "../../../settings/Layout";
import {Layout} from "../../../settings/Layout";
import {formatTime} from "../../../DateUtils";
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import {ALL_RULE_TYPES} from "../../../mjolnir/BanList";
@ -40,6 +42,10 @@ import {WIDGET_LAYOUT_EVENT_TYPE} from "../../../stores/widgets/WidgetLayoutStor
import {objectHasDiff} from "../../../utils/objects";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import Tooltip from "../elements/Tooltip";
import { EditorStateTransfer } from "../../../utils/EditorStateTransfer";
import { RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import {StaticNotificationState} from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "./NotificationBadge";
const eventTileTypes = {
[EventType.RoomMessage]: 'messages.MessageEvent',
@ -169,98 +175,130 @@ const MAX_READ_AVATARS = 5;
// | '--------------------------------------' |
// '----------------------------------------------------------'
interface IReadReceiptProps {
userId: string;
roomMember: RoomMember;
ts: number;
}
interface IProps {
// the MatrixEvent to show
mxEvent: MatrixEvent;
// true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
// might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
// references the same this.props.mxEvent.
isRedacted?: boolean;
// true if this is a continuation of the previous event (which has the
// effect of not showing another avatar/displayname
continuation?: boolean;
// true if this is the last event in the timeline (which has the effect
// of always showing the timestamp)
last?: boolean;
// true if the event is the last event in a section (adds a css class for
// targeting)
lastInSection?: boolean;
// True if the event is the last successful (sent) event.
lastSuccessful?: boolean;
// true if this is search context (which has the effect of greying out
// the text
contextual?: boolean;
// a list of words to highlight, ordered by longest first
highlights?: string[];
// link URL for the highlights
highlightLink?: string;
// should show URL previews for this event
showUrlPreview?: boolean;
// is this the focused event
isSelectedEvent?: boolean;
// callback called when dynamic content in events are loaded
onHeightChanged?: () => void;
// a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'.
readReceipts?: IReadReceiptProps[];
// opaque readreceipt info for each userId; used by ReadReceiptMarker
// to manage its animations. Should be an empty object when the room
// first loads
readReceiptMap?: any;
// A function which is used to check if the parent panel is being
// unmounted, to avoid unnecessary work. Should return true if we
// are being unmounted.
checkUnmounting?: () => boolean;
// the status of this event - ie, mxEvent.status. Denormalised to here so
// that we can tell when it changes.
eventSendStatus?: string;
// the shape of the tile. by default, the layout is intended for the
// normal room timeline. alternative values are: "file_list", "file_grid"
// and "notif". This could be done by CSS, but it'd be horribly inefficient.
// It could also be done by subclassing EventTile, but that'd be quite
// boiilerplatey. So just make the necessary render decisions conditional
// for now.
tileShape?: 'notif' | 'file_grid' | 'reply' | 'reply_preview';
// show twelve hour timestamps
isTwelveHour?: boolean;
// helper function to access relations for this event
getRelationsForEvent?: (eventId: string, relationType: string, eventType: string) => Relations;
// whether to show reactions for this event
showReactions?: boolean;
// which layout to use
layout: Layout;
// whether or not to show flair at all
enableFlair?: boolean;
// whether or not to show read receipts
showReadReceipts?: boolean;
// Used while editing, to pass the event, and to preserve editor state
// from one editor instance to another when remounting the editor
// upon receiving the remote echo for an unsent event.
editState?: EditorStateTransfer;
// Event ID of the event replacing the content of this event, if any
replacingEventId?: string;
// Helper to build permalinks for the room
permalinkCreator?: RoomPermalinkCreator;
}
interface IState {
// Whether the action bar is focused.
actionBarFocused: boolean;
// Whether all read receipts are being displayed. If not, only display
// a truncation of them.
allReadAvatars: boolean;
// Whether the event's sender has been verified.
verified: string;
// Whether onRequestKeysClick has been called since mounting.
previouslyRequestedKeys: boolean;
// The Relations model from the JS SDK for reactions to `mxEvent`
reactions: Relations;
}
@replaceableComponent("views.rooms.EventTile")
export default class EventTile extends React.Component {
static propTypes = {
/* the MatrixEvent to show */
mxEvent: PropTypes.object.isRequired,
/* true if mxEvent is redacted. This is a prop because using mxEvent.isRedacted()
* might not be enough when deciding shouldComponentUpdate - prevProps.mxEvent
* references the same this.props.mxEvent.
*/
isRedacted: PropTypes.bool,
/* true if this is a continuation of the previous event (which has the
* effect of not showing another avatar/displayname
*/
continuation: PropTypes.bool,
/* true if this is the last event in the timeline (which has the effect
* of always showing the timestamp)
*/
last: PropTypes.bool,
// true if the event is the last event in a section (adds a css class for
// targeting)
lastInSection: PropTypes.bool,
// True if the event is the last successful (sent) event.
isLastSuccessful: PropTypes.bool,
/* true if this is search context (which has the effect of greying out
* the text
*/
contextual: PropTypes.bool,
/* a list of words to highlight, ordered by longest first */
highlights: PropTypes.array,
/* link URL for the highlights */
highlightLink: PropTypes.string,
/* should show URL previews for this event */
showUrlPreview: PropTypes.bool,
/* is this the focused event */
isSelectedEvent: PropTypes.bool,
/* callback called when dynamic content in events are loaded */
onHeightChanged: PropTypes.func,
/* a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. */
readReceipts: PropTypes.arrayOf(PropTypes.object),
/* opaque readreceipt info for each userId; used by ReadReceiptMarker
* to manage its animations. Should be an empty object when the room
* first loads
*/
readReceiptMap: PropTypes.object,
/* A function which is used to check if the parent panel is being
* unmounted, to avoid unnecessary work. Should return true if we
* are being unmounted.
*/
checkUnmounting: PropTypes.func,
/* the status of this event - ie, mxEvent.status. Denormalised to here so
* that we can tell when it changes. */
eventSendStatus: PropTypes.string,
/* the shape of the tile. by default, the layout is intended for the
* normal room timeline. alternative values are: "file_list", "file_grid"
* and "notif". This could be done by CSS, but it'd be horribly inefficient.
* It could also be done by subclassing EventTile, but that'd be quite
* boiilerplatey. So just make the necessary render decisions conditional
* for now.
*/
tileShape: PropTypes.string,
// show twelve hour timestamps
isTwelveHour: PropTypes.bool,
// helper function to access relations for this event
getRelationsForEvent: PropTypes.func,
// whether to show reactions for this event
showReactions: PropTypes.bool,
// which layout to use
layout: LayoutPropType,
// whether or not to show flair at all
enableFlair: PropTypes.bool,
};
export default class EventTile extends React.Component<IProps, IState> {
private suppressReadReceiptAnimation: boolean;
private isListeningForReceipts: boolean;
private tile = React.createRef();
private replyThread = React.createRef();
static defaultProps = {
// no-op function because onHeightChanged is optional yet some sub-components assume its existence
@ -287,26 +325,22 @@ export default class EventTile extends React.Component {
};
// don't do RR animations until we are mounted
this._suppressReadReceiptAnimation = true;
this._tile = createRef();
this._replyThread = createRef();
this.suppressReadReceiptAnimation = true;
// Throughout the component we manage a read receipt listener to see if our tile still
// qualifies for a "sent" or "sending" state (based on their relevant conditions). We
// don't want to over-subscribe to the read receipt events being fired, so we use a flag
// to determine if we've already subscribed and use a combination of other flags to find
// out if we should even be subscribed at all.
this._isListeningForReceipts = false;
this.isListeningForReceipts = false;
}
/**
* When true, the tile qualifies for some sort of special read receipt. This could be a 'sending'
* or 'sent' receipt, for example.
* @returns {boolean}
* @private
*/
get _isEligibleForSpecialReceipt() {
private get isEligibleForSpecialReceipt() {
// First, if there are other read receipts then just short-circuit this.
if (this.props.readReceipts && this.props.readReceipts.length > 0) return false;
if (!this.props.mxEvent) return false;
@ -335,9 +369,9 @@ export default class EventTile extends React.Component {
return true;
}
get _shouldShowSentReceipt() {
private get shouldShowSentReceipt() {
// If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false;
if (!this.isEligibleForSpecialReceipt) return false;
// We only show the 'sent' receipt on the last successful event.
if (!this.props.lastSuccessful) return false;
@ -355,9 +389,9 @@ export default class EventTile extends React.Component {
return true;
}
get _shouldShowSendingReceipt() {
private get shouldShowSendingReceipt() {
// If we're not even eligible, don't show the receipt.
if (!this._isEligibleForSpecialReceipt) return false;
if (!this.isEligibleForSpecialReceipt) return false;
// Check the event send status to see if we are pending. Null/undefined status means the
// message was sent, so check for that and 'sent' explicitly.
@ -371,22 +405,22 @@ export default class EventTile extends React.Component {
// TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase
UNSAFE_componentWillMount() {
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
}
componentDidMount() {
this._suppressReadReceiptAnimation = false;
this.suppressReadReceiptAnimation = false;
const client = this.context;
client.on("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.on("userTrustStatusChanged", this.onUserVerificationChanged);
this.props.mxEvent.on("Event.decrypted", this._onDecrypted);
this.props.mxEvent.on("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.on("Event.relationsCreated", this._onReactionsCreated);
this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated);
}
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
client.on("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = true;
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
client.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
}
@ -396,7 +430,7 @@ export default class EventTile extends React.Component {
// re-check the sender verification as outgoing events progress through
// the send process.
if (nextProps.eventSendStatus !== this.props.eventSendStatus) {
this._verifyEvent(nextProps.mxEvent);
this.verifyEvent(nextProps.mxEvent);
}
}
@ -405,35 +439,35 @@ export default class EventTile extends React.Component {
return true;
}
return !this._propsEqual(this.props, nextProps);
return !this.propsEqual(this.props, nextProps);
}
componentWillUnmount() {
const client = this.context;
client.removeListener("deviceVerificationChanged", this.onDeviceVerificationChanged);
client.removeListener("userTrustStatusChanged", this.onUserVerificationChanged);
client.removeListener("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = false;
this.props.mxEvent.removeListener("Event.decrypted", this._onDecrypted);
client.removeListener("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = false;
this.props.mxEvent.removeListener("Event.decrypted", this.onDecrypted);
if (this.props.showReactions) {
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
}
}
componentDidUpdate(prevProps, prevState, snapshot) {
// If we're not listening for receipts and expect to be, register a listener.
if (!this._isListeningForReceipts && (this._shouldShowSentReceipt || this._shouldShowSendingReceipt)) {
this.context.on("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = true;
if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
this.context.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true;
}
}
_onRoomReceipt = (ev, room) => {
private onRoomReceipt = (ev, room) => {
// ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId());
if (room !== tileRoom) return;
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt && !this._isListeningForReceipts) {
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) {
return;
}
@ -441,36 +475,36 @@ export default class EventTile extends React.Component {
// the getters we use here to determine what needs rendering.
this.forceUpdate(() => {
// Per elsewhere in this file, we can remove the listener once we will have no further purpose for it.
if (!this._shouldShowSentReceipt && !this._shouldShowSendingReceipt) {
this.context.removeListener("Room.receipt", this._onRoomReceipt);
this._isListeningForReceipts = false;
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt) {
this.context.removeListener("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = false;
}
});
};
/** called when the event is decrypted after we show it.
*/
_onDecrypted = () => {
private onDecrypted = () => {
// we need to re-verify the sending device.
// (we call onHeightChanged in _verifyEvent to handle the case where decryption
// (we call onHeightChanged in verifyEvent to handle the case where decryption
// has caused a change in size of the event tile)
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
this.forceUpdate();
};
onDeviceVerificationChanged = (userId, device) => {
private onDeviceVerificationChanged = (userId, device) => {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
}
};
onUserVerificationChanged = (userId, _trustStatus) => {
private onUserVerificationChanged = (userId, _trustStatus) => {
if (userId === this.props.mxEvent.getSender()) {
this._verifyEvent(this.props.mxEvent);
this.verifyEvent(this.props.mxEvent);
}
};
async _verifyEvent(mxEvent) {
private async verifyEvent(mxEvent) {
if (!mxEvent.isEncrypted()) {
return;
}
@ -524,7 +558,7 @@ export default class EventTile extends React.Component {
}, this.props.onHeightChanged); // Decryption may have caused a change in size
}
_propsEqual(objA, objB) {
private propsEqual(objA, objB) {
const keysA = Object.keys(objA);
const keysB = Object.keys(objB);
@ -591,7 +625,7 @@ export default class EventTile extends React.Component {
};
getReadAvatars() {
if (this._shouldShowSentReceipt || this._shouldShowSendingReceipt) {
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />;
}
@ -638,7 +672,7 @@ export default class EventTile extends React.Component {
leftOffset={left} hidden={hidden}
readReceiptInfo={readReceiptInfo}
checkUnmounting={this.props.checkUnmounting}
suppressAnimation={this._suppressReadReceiptAnimation}
suppressAnimation={this.suppressReadReceiptAnimation}
onClick={this.toggleAllReadAvatars}
timestamp={receipt.ts}
showTwelveHour={this.props.isTwelveHour}
@ -695,7 +729,7 @@ export default class EventTile extends React.Component {
});
};
_renderE2EPadlock() {
private renderE2EPadlock() {
const ev = this.props.mxEvent;
// event could not be decrypted
@ -744,9 +778,9 @@ export default class EventTile extends React.Component {
});
};
getTile = () => this._tile.current;
getTile = () => this.tile.current;
getReplyThread = () => this._replyThread.current;
getReplyThread = () => this.replyThread.current;
getReactions = () => {
if (
@ -766,11 +800,11 @@ export default class EventTile extends React.Component {
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
};
_onReactionsCreated = (relationType, eventType) => {
private onReactionsCreated = (relationType, eventType) => {
if (relationType !== "m.annotation" || eventType !== "m.reaction") {
return;
}
this.props.mxEvent.removeListener("Event.relationsCreated", this._onReactionsCreated);
this.props.mxEvent.removeListener("Event.relationsCreated", this.onReactionsCreated);
this.setState({
reactions: this.getReactions(),
});
@ -835,7 +869,6 @@ export default class EventTile extends React.Component {
mx_EventTile_12hr: this.props.isTwelveHour,
// Note: we keep the `sending` state class for tests, not for our styles
mx_EventTile_sending: !isEditing && isSending,
mx_EventTile_notSent: this.props.eventSendStatus === 'not_sent',
mx_EventTile_highlight: this.props.tileShape === 'notif' ? false : this.shouldHighlight(),
mx_EventTile_selected: this.props.isSelectedEvent,
mx_EventTile_continuation: this.props.tileShape ? '' : this.props.continuation,
@ -858,8 +891,6 @@ export default class EventTile extends React.Component {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId());
}
const readAvatars = this.getReadAvatars();
let avatar;
let sender;
let avatarSize;
@ -894,7 +925,7 @@ export default class EventTile extends React.Component {
// so that the correct avatar is shown as the text is
// `$target accepted the invitation for $email`
if (this.props.mxEvent.getContent().third_party_invite) {
member = this.props.mxEvent.target;
member = this.props.mxEvent.target;
} else {
member = this.props.mxEvent.sender;
}
@ -911,8 +942,9 @@ export default class EventTile extends React.Component {
if (needsSenderProfile) {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair} />;
mxEvent={this.props.mxEvent}
enableFlair={this.props.enableFlair}
/>;
} else {
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={this.props.enableFlair} />;
}
@ -975,18 +1007,28 @@ export default class EventTile extends React.Component {
}
const linkedTimestamp = <a
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>;
href={permalink}
onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)}
>
{ timestamp }
</a>;
const useIRCLayout = this.props.layout == Layout.IRC;
const groupTimestamp = !useIRCLayout ? linkedTimestamp : null;
const ircTimestamp = useIRCLayout ? linkedTimestamp : null;
const groupPadlock = !useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this._renderE2EPadlock();
const groupPadlock = !useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
const ircPadlock = useIRCLayout && !isBubbleMessage && this.renderE2EPadlock();
let msgOption;
if (this.props.showReadReceipts) {
const readAvatars = this.getReadAvatars();
msgOption = (
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
);
}
switch (this.props.tileShape) {
case 'notif': {
@ -1007,12 +1049,13 @@ export default class EventTile extends React.Component {
</a>
</div>
<div className="mx_EventTile_line">
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
onHeightChanged={this.props.onHeightChanged}
/>
</div>
</div>
);
@ -1021,13 +1064,14 @@ export default class EventTile extends React.Component {
return (
<div className={classes} aria-live={ariaLive} aria-atomic="true">
<div className="mx_EventTile_line">
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
tileShape={this.props.tileShape}
onHeightChanged={this.props.onHeightChanged}
/>
</div>
<a
className="mx_EventTile_senderDetailsLink"
@ -1051,7 +1095,7 @@ export default class EventTile extends React.Component {
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.replyThread,
);
}
return (
@ -1064,13 +1108,14 @@ export default class EventTile extends React.Component {
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged}
replacingEventId={this.props.replacingEventId}
showUrlPreview={false}
/>
</div>
</div>
);
@ -1080,7 +1125,7 @@ export default class EventTile extends React.Component {
this.props.mxEvent,
this.props.onHeightChanged,
this.props.permalinkCreator,
this._replyThread,
this.replyThread,
this.props.layout,
);
@ -1094,22 +1139,21 @@ export default class EventTile extends React.Component {
{ groupTimestamp }
{ groupPadlock }
{ thread }
<EventTileType ref={this._tile}
mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged} />
<EventTileType ref={this.tile}
mxEvent={this.props.mxEvent}
replacingEventId={this.props.replacingEventId}
editState={this.props.editState}
highlights={this.props.highlights}
highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview}
permalinkCreator={this.props.permalinkCreator}
onHeightChanged={this.props.onHeightChanged}
/>
{ keyRequestInfo }
{ reactionsRow }
{ actionBar }
</div>
<div className="mx_EventTile_msgOption">
{ readAvatars }
</div>
{msgOption}
{
// The avatar goes after the event tile as it's absolutely positioned to be over the
// event tile line, so needs to be later in the DOM so it appears on top (this avoids
@ -1173,18 +1217,26 @@ function E2ePadlockUnknown(props) {
function E2ePadlockUnauthenticated(props) {
return (
<E2ePadlock title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")} icon="unauthenticated" {...props} />
<E2ePadlock
title={_t("The authenticity of this encrypted message can't be guaranteed on this device.")}
icon="unauthenticated"
{...props}
/>
);
}
class E2ePadlock extends React.Component {
static propTypes = {
icon: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
};
interface IE2ePadlockProps {
icon: string;
title: string;
}
constructor() {
super();
interface IE2ePadlockState {
hover: boolean;
}
class E2ePadlock extends React.Component<IE2ePadlockProps, IE2ePadlockState> {
constructor(props) {
super(props);
this.state = {
hover: false,
@ -1202,14 +1254,13 @@ class E2ePadlock extends React.Component {
render() {
let tooltip = null;
if (this.state.hover) {
tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} dir="auto" />;
tooltip = <Tooltip className="mx_EventTile_e2eIcon_tooltip" label={this.props.title} />;
}
const classes = `mx_EventTile_e2eIcon mx_EventTile_e2eIcon_${this.props.icon}`;
return (
<div
className={classes}
onClick={this.onClick}
onMouseEnter={this.onHoverStart}
onMouseLeave={this.onHoverEnd}
>{tooltip}</div>
@ -1226,8 +1277,8 @@ interface ISentReceiptState {
}
class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptState> {
constructor() {
super();
constructor(props) {
super(props);
this.state = {
hover: false,
@ -1244,11 +1295,19 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
render() {
const isSent = !this.props.messageState || this.props.messageState === 'sent';
const isFailed = this.props.messageState === 'not_sent';
const receiptClasses = classNames({
'mx_EventTile_receiptSent': isSent,
'mx_EventTile_receiptSending': !isSent,
'mx_EventTile_receiptSending': !isSent && !isFailed,
});
let nonCssBadge = null;
if (isFailed) {
nonCssBadge = <NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
/>;
}
let tooltip = null;
if (this.state.hover) {
let label = _t("Sending your message...");
@ -1256,6 +1315,8 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
label = _t("Encrypting your message...");
} else if (isSent) {
label = _t("Your message was sent");
} else if (isFailed) {
label = _t("Failed to send");
}
// The yOffset is somewhat arbitrary - it just brings the tooltip down to be more associated
// with the read receipt.
@ -1264,6 +1325,7 @@ class SentReceipt extends React.PureComponent<ISentReceiptProps, ISentReceiptSta
return <span className="mx_EventTile_readAvatars">
<span className={receiptClasses} onMouseEnter={this.onHoverStart} onMouseLeave={this.onHoverEnd}>
{nonCssBadge}
{tooltip}
</span>
</span>;

View file

@ -128,8 +128,8 @@ export default class LinkPreviewWidget extends React.Component {
let img;
if (image) {
img = <div className="mx_LinkPreviewWidget_image" style={{ height: thumbHeight }}>
<img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={image} onClick={this.onImageClick} />
</div>;
<img style={{ maxWidth: imageMaxWidth, maxHeight: imageMaxHeight }} src={image} onClick={this.onImageClick} />
</div>;
}
// The description includes &-encoded HTML entities, we decode those as React treats the thing as an

View file

@ -30,6 +30,7 @@ import {RightPanelPhases} from "../../../stores/RightPanelStorePhases";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
const INITIAL_LOAD_NUM_MEMBERS = 30;
const INITIAL_LOAD_NUM_INVITED = 5;
@ -460,7 +461,7 @@ export default class MemberList extends React.Component {
const chat = CommunityPrototypeStore.instance.getSelectedCommunityGeneralChat();
if (chat && chat.roomId === this.props.roomId) {
inviteButtonText = _t("Invite to this community");
} else if (room.isSpaceRoom()) {
} else if (SettingsStore.getValue("feature_spaces") && room.isSpaceRoom()) {
inviteButtonText = _t("Invite to this space");
}
@ -492,7 +493,7 @@ export default class MemberList extends React.Component {
let previousPhase = RightPanelPhases.RoomSummary;
// We have no previousPhase for when viewing a MemberList from a Space
let scopeHeader;
if (room?.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && room?.isSpaceRoom()) {
previousPhase = undefined;
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />

View file

@ -1,5 +1,5 @@
/*
Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -13,15 +13,18 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React, {createRef} from 'react';
import React from 'react';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import {MatrixClientPeg} from '../../../MatrixClientPeg';
import * as sdk from '../../../index';
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {RoomMember} from "matrix-js-sdk/src/models/room-member";
import dis from '../../../dispatcher/dispatcher';
import { ActionPayload } from "../../../dispatcher/payloads";
import Stickerpicker from './Stickerpicker';
import { makeRoomPermalink } from '../../../utils/permalinks/Permalinks';
import { makeRoomPermalink, RoomPermalinkCreator } from '../../../utils/permalinks/Permalinks';
import ContentMessages from '../../../ContentMessages';
import E2EIcon from './E2EIcon';
import SettingsStore from "../../../settings/SettingsStore";
@ -29,24 +32,32 @@ import {aboveLeftOf, ContextMenu, ContextMenuTooltipButton, useContextMenu} from
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import ReplyPreview from "./ReplyPreview";
import {UIFeature} from "../../../settings/UIFeature";
import WidgetStore from "../../../stores/WidgetStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import ActiveWidgetStore from "../../../stores/ActiveWidgetStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import VoiceRecordComposerTile from "./VoiceRecordComposerTile";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {RecordingState} from "../../../voice/VoiceRecording";
import Tooltip, {Alignment} from "../elements/Tooltip";
import ResizeNotifier from "../../../utils/ResizeNotifier";
import { E2EStatus } from '../../../utils/ShieldUtils';
import SendMessageComposer from "./SendMessageComposer";
function ComposerAvatar(props) {
interface IComposerAvatarProps {
me: object;
}
function ComposerAvatar(props: IComposerAvatarProps) {
const MemberStatusMessageAvatar = sdk.getComponent('avatars.MemberStatusMessageAvatar');
return <div className="mx_MessageComposer_avatar">
<MemberStatusMessageAvatar member={props.me} width={24} height={24} />
</div>;
}
ComposerAvatar.propTypes = {
me: PropTypes.object.isRequired,
};
interface ISendButtonProps {
onClick: () => void;
}
function SendButton(props) {
function SendButton(props: ISendButtonProps) {
return (
<AccessibleTooltipButton
className="mx_MessageComposer_sendMessage"
@ -56,10 +67,6 @@ function SendButton(props) {
);
}
SendButton.propTypes = {
onClick: PropTypes.func.isRequired,
};
const EmojiButton = ({addEmoji}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
@ -67,7 +74,7 @@ const EmojiButton = ({addEmoji}) => {
if (menuDisplayed) {
const buttonRect = button.current.getBoundingClientRect();
const EmojiPicker = sdk.getComponent('emojipicker.EmojiPicker');
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} catchTab={false}>
contextMenu = <ContextMenu {...aboveLeftOf(buttonRect)} onFinished={closeMenu} managed={false}>
<EmojiPicker onChoose={addEmoji} showQuickReactions={true} />
</ContextMenu>;
}
@ -97,39 +104,39 @@ const EmojiButton = ({addEmoji}) => {
</React.Fragment>;
};
class UploadButton extends React.Component {
static propTypes = {
roomId: PropTypes.string.isRequired,
}
interface IUploadButtonProps {
roomId: string;
}
class UploadButton extends React.Component<IUploadButtonProps> {
private uploadInput = React.createRef<HTMLInputElement>();
private dispatcherRef: string;
constructor(props) {
super(props);
this.onUploadClick = this.onUploadClick.bind(this);
this.onUploadFileInputChange = this.onUploadFileInputChange.bind(this);
this._uploadInput = createRef();
this._dispatcherRef = dis.register(this.onAction);
this.dispatcherRef = dis.register(this.onAction);
}
componentWillUnmount() {
dis.unregister(this._dispatcherRef);
dis.unregister(this.dispatcherRef);
}
onAction = payload => {
private onAction = (payload: ActionPayload) => {
if (payload.action === "upload_file") {
this.onUploadClick();
}
};
onUploadClick(ev) {
private onUploadClick = () => {
if (MatrixClientPeg.get().isGuest()) {
dis.dispatch({action: 'require_registration'});
return;
}
this._uploadInput.current.click();
this.uploadInput.current.click();
}
onUploadFileInputChange(ev) {
private onUploadFileInputChange = (ev: React.ChangeEvent<HTMLInputElement>) => {
if (ev.target.files.length === 0) return;
// take a copy so we can safely reset the value of the form control
@ -159,7 +166,7 @@ class UploadButton extends React.Component {
title={_t('Upload file')}
>
<input
ref={this._uploadInput}
ref={this.uploadInput}
type="file"
style={uploadInputStyle}
multiple
@ -170,29 +177,49 @@ class UploadButton extends React.Component {
}
}
interface IProps {
room: Room;
resizeNotifier: ResizeNotifier;
permalinkCreator: RoomPermalinkCreator;
replyToEvent?: MatrixEvent;
e2eStatus?: E2EStatus;
}
interface IState {
tombstone: MatrixEvent;
canSendMessages: boolean;
isComposerEmpty: boolean;
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
me?: RoomMember;
}
@replaceableComponent("views.rooms.MessageComposer")
export default class MessageComposer extends React.Component {
export default class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef: string;
private messageComposerInput: SendMessageComposer;
private voiceRecordingButton: VoiceRecordComposerTile;
constructor(props) {
super(props);
this.onInputStateChanged = this.onInputStateChanged.bind(this);
this._onRoomStateEvents = this._onRoomStateEvents.bind(this);
this._onTombstoneClick = this._onTombstoneClick.bind(this);
this.renderPlaceholderText = this.renderPlaceholderText.bind(this);
WidgetStore.instance.on(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.on('update', this._onActiveWidgetUpdate);
this._dispatcherRef = null;
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
this.state = {
tombstone: this._getRoomTombstone(),
tombstone: this.getRoomTombstone(),
canSendMessages: this.props.room.maySendMessage(),
hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room),
joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room),
isComposerEmpty: true,
haveRecording: false,
recordingTimeLeftSeconds: null, // when set to a number, shows a toast
};
}
onAction = (payload) => {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this.onRoomStateEvents);
this.waitForOwnMember();
}
private onAction = (payload: ActionPayload) => {
if (payload.action === 'reply_to_event') {
// add a timeout for the reply preview to be rendered, so
// that the ScrollPanel listening to the resizeNotifier can
@ -204,21 +231,7 @@ export default class MessageComposer extends React.Component {
}
};
_onWidgetUpdate = () => {
this.setState({hasConference: WidgetStore.instance.doesRoomHaveConference(this.props.room)});
};
_onActiveWidgetUpdate = () => {
this.setState({joinedConference: WidgetStore.instance.isJoinedToConferenceIn(this.props.room)});
};
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.get().on("RoomState.events", this._onRoomStateEvents);
this._waitForOwnMember();
}
_waitForOwnMember() {
private waitForOwnMember() {
// if we have the member already, do that
const me = this.props.room.getMember(MatrixClientPeg.get().getUserId());
if (me) {
@ -236,35 +249,28 @@ export default class MessageComposer extends React.Component {
componentWillUnmount() {
if (MatrixClientPeg.get()) {
MatrixClientPeg.get().removeListener("RoomState.events", this._onRoomStateEvents);
MatrixClientPeg.get().removeListener("RoomState.events", this.onRoomStateEvents);
}
WidgetStore.instance.removeListener(UPDATE_EVENT, this._onWidgetUpdate);
ActiveWidgetStore.removeListener('update', this._onActiveWidgetUpdate);
VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate);
dis.unregister(this.dispatcherRef);
}
_onRoomStateEvents(ev, state) {
private onRoomStateEvents = (ev, state) => {
if (ev.getRoomId() !== this.props.room.roomId) return;
if (ev.getType() === 'm.room.tombstone') {
this.setState({tombstone: this._getRoomTombstone()});
this.setState({tombstone: this.getRoomTombstone()});
}
if (ev.getType() === 'm.room.power_levels') {
this.setState({canSendMessages: this.props.room.maySendMessage()});
}
}
_getRoomTombstone() {
private getRoomTombstone() {
return this.props.room.currentState.getStateEvents('m.room.tombstone', '');
}
onInputStateChanged(inputState) {
// Merge the new input state with old to support partial updates
inputState = Object.assign({}, this.state.inputState, inputState);
this.setState({inputState});
}
_onTombstoneClick(ev) {
private onTombstoneClick = (ev) => {
ev.preventDefault();
const replacementRoomId = this.state.tombstone.getContent()['replacement_room'];
@ -294,7 +300,7 @@ export default class MessageComposer extends React.Component {
});
}
renderPlaceholderText() {
private renderPlaceholderText = () => {
if (this.props.replyToEvent) {
if (this.props.e2eStatus) {
return _t('Send an encrypted reply…');
@ -317,7 +323,15 @@ export default class MessageComposer extends React.Component {
});
}
sendMessage = () => {
sendMessage = async () => {
if (this.state.haveRecording && this.voiceRecordingButton) {
// There shouldn't be any text message to send when a voice recording is active, so
// just send out the voice recording.
await this.voiceRecordingButton.send();
return;
}
// XXX: Private function access
this.messageComposerInput._sendMessage();
}
@ -327,8 +341,18 @@ export default class MessageComposer extends React.Component {
});
}
onVoiceUpdate = (haveRecording: boolean) => {
this.setState({haveRecording});
private onVoiceStoreUpdate = () => {
const recording = VoiceRecordingStore.instance.activeRecording;
this.setState({haveRecording: !!recording});
if (recording) {
// We show a little heads up that the recording is about to automatically end soon. The 3s
// display time is completely arbitrary. Note that we don't need to deregister the listener
// because the recording instance will clean that up for us.
recording.on(RecordingState.EndingSoon, ({secondsLeft}) => {
this.setState({recordingTimeLeftSeconds: secondsLeft});
setTimeout(() => this.setState({recordingTimeLeftSeconds: null}), 3000);
});
}
};
render() {
@ -352,7 +376,6 @@ export default class MessageComposer extends React.Component {
permalinkCreator={this.props.permalinkCreator}
replyToEvent={this.props.replyToEvent}
onChange={this.onChange}
// TODO: @@ TravisR - Disabling the composer doesn't work
disabled={this.state.haveRecording}
/>,
);
@ -373,8 +396,8 @@ export default class MessageComposer extends React.Component {
if (SettingsStore.getValue("feature_voice_messages")) {
controls.push(<VoiceRecordComposerTile
key="controls_voice_record"
room={this.props.room}
onRecording={this.onVoiceUpdate} />);
ref={c => this.voiceRecordingButton = c}
room={this.props.room} />);
}
if (!this.state.isComposerEmpty || this.state.haveRecording) {
@ -388,7 +411,7 @@ export default class MessageComposer extends React.Component {
const continuesLink = replacementRoomId ? (
<a href={makeRoomPermalink(replacementRoomId)}
className="mx_MessageComposer_roomReplaced_link"
onClick={this._onTombstoneClick}
onClick={this.onTombstoneClick}
>
{_t("The conversation continues here.")}
</a>
@ -396,7 +419,9 @@ export default class MessageComposer extends React.Component {
controls.push(<div className="mx_MessageComposer_replaced_wrapper" key="room_replaced">
<div className="mx_MessageComposer_replaced_valign">
<img className="mx_MessageComposer_roomReplaced_icon" src={require("../../../../res/img/room_replaced.svg")} />
<img className="mx_MessageComposer_roomReplaced_icon"
src={require("../../../../res/img/room_replaced.svg")}
/>
<span className="mx_MessageComposer_roomReplaced_header">
{_t("This room has been replaced and is no longer active.")}
</span><br />
@ -411,8 +436,18 @@ export default class MessageComposer extends React.Component {
);
}
let recordingTooltip;
const secondsLeft = Math.round(this.state.recordingTimeLeftSeconds);
if (secondsLeft) {
recordingTooltip = <Tooltip
label={_t("%(seconds)ss left", {seconds: secondsLeft})}
alignment={Alignment.Top} yOffset={-50}
/>;
}
return (
<div className="mx_MessageComposer mx_GroupLayout">
{recordingTooltip}
<div className="mx_MessageComposer_wrapper">
<ReplyPreview permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row">
@ -423,14 +458,3 @@ export default class MessageComposer extends React.Component {
);
}
}
MessageComposer.propTypes = {
// js-sdk Room object
room: PropTypes.object.isRequired,
// string representing the current voip call state
callState: PropTypes.string,
// string representing the current room app drawer state
showApps: PropTypes.bool,
};

View file

@ -30,7 +30,7 @@ interface IProps {
* If true, the badge will show a count if at all possible. This is typically
* used to override the user's preference for things like room sublists.
*/
forceCount: boolean;
forceCount?: boolean;
/**
* The room ID, if any, the badge represents.

View file

@ -53,9 +53,9 @@ export default class PinnedEventTile extends React.Component {
if (index !== -1) {
pinned.splice(index, 1);
MatrixClientPeg.get().sendStateEvent(this.props.mxRoom.roomId, 'm.room.pinned_events', {pinned}, '')
.then(() => {
if (this.props.onUnpinned) this.props.onUnpinned();
});
.then(() => {
if (this.props.onUnpinned) this.props.onUnpinned();
});
} else if (this.props.onUnpinned) this.props.onUnpinned();
}
};
@ -98,8 +98,11 @@ export default class PinnedEventTile extends React.Component {
{ formatFullDate(new Date(this.props.mxEvent.getTs())) }
</span>
<div className="mx_PinnedEventTile_message">
<MessageEvent mxEvent={this.props.mxEvent} className="mx_PinnedEventTile_body" maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
<MessageEvent
mxEvent={this.props.mxEvent}
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
/>
</div>
</div>

View file

@ -64,10 +64,10 @@ export default class PinnedEventsPanel extends React.Component {
pinnedEvents.getContent().pinned.map((eventId) => {
promises.push(cli.getEventTimeline(this.props.room.getUnfilteredTimelineSet(), eventId, 0).then(
(timeline) => {
const event = timeline.getEvents().find((e) => e.getId() === eventId);
return {eventId, timeline, event};
}).catch((err) => {
(timeline) => {
const event = timeline.getEvents().find((e) => e.getId() === eventId);
return {eventId, timeline, event};
}).catch((err) => {
console.error("Error looking up pinned event " + eventId + " in room " + this.props.room.roomId);
console.error(err);
return null; // return lack of context to avoid unhandled errors
@ -113,10 +113,14 @@ export default class PinnedEventsPanel extends React.Component {
}
return this.state.pinned.map((context) => {
return (<PinnedEventTile key={context.event.getId()}
mxRoom={this.props.room}
mxEvent={context.event}
onUnpinned={this._updatePinnedMessages} />);
return (
<PinnedEventTile
key={context.event.getId()}
mxRoom={this.props.room}
mxEvent={context.event}
onUnpinned={this._updatePinnedMessages}
/>
);
});
}

View file

@ -17,22 +17,13 @@ limitations under the License.
import React, {createRef} from 'react';
import PropTypes from 'prop-types';
import '../../../VelocityBounce';
import { _t } from '../../../languageHandler';
import {formatDate} from '../../../DateUtils';
import Velociraptor from "../../../Velociraptor";
import NodeAnimator from "../../../NodeAnimator";
import * as sdk from "../../../index";
import {toPx} from "../../../utils/units";
import {replaceableComponent} from "../../../utils/replaceableComponent";
let bounce = false;
try {
if (global.localStorage) {
bounce = global.localStorage.getItem('avatar_bounce') == 'true';
}
} catch (e) {
}
@replaceableComponent("views.rooms.ReadReceiptMarker")
export default class ReadReceiptMarker extends React.PureComponent {
static propTypes = {
@ -115,7 +106,18 @@ export default class ReadReceiptMarker extends React.PureComponent {
// we've already done our display - nothing more to do.
return;
}
this._animateMarker();
}
componentDidUpdate(prevProps) {
const differentLeftOffset = prevProps.leftOffset !== this.props.leftOffset;
const visibilityChanged = prevProps.hidden !== this.props.hidden;
if (differentLeftOffset || visibilityChanged) {
this._animateMarker();
}
}
_animateMarker() {
// treat new RRs as though they were off the top of the screen
let oldTop = -15;
@ -139,42 +141,18 @@ export default class ReadReceiptMarker extends React.PureComponent {
}
const startStyles = [];
const enterTransitionOpts = [];
if (oldInfo && oldInfo.left) {
// start at the old height and in the old h pos
startStyles.push({ top: startTopOffset+"px",
left: toPx(oldInfo.left) });
const reorderTransitionOpts = {
duration: 100,
easing: 'easeOut',
};
enterTransitionOpts.push(reorderTransitionOpts);
}
// then shift to the rightmost column,
// and then it will drop down to its resting position
//
// XXX: We use a small left value to trick velocity-animate into actually animating.
// This is a very annoying bug where if it thinks there's no change to `left` then it'll
// skip applying it, thus making our read receipt at +14px instead of +0px like it
// should be. This does cause a tiny amount of drift for read receipts, however with a
// value so small it's not perceived by a user.
// Note: Any smaller values (or trying to interchange units) might cause read receipts to
// fail to fall down or cause gaps.
startStyles.push({ top: startTopOffset+'px', left: '1px' });
enterTransitionOpts.push({
duration: bounce ? Math.min(Math.log(Math.abs(startTopOffset)) * 200, 3000) : 300,
easing: bounce ? 'easeOutBounce' : 'easeOutCubic',
});
startStyles.push({ top: startTopOffset+'px', left: '0' });
this.setState({
suppressDisplay: false,
startStyles: startStyles,
enterTransitionOpts: enterTransitionOpts,
});
}
@ -187,7 +165,6 @@ export default class ReadReceiptMarker extends React.PureComponent {
const style = {
left: toPx(this.props.leftOffset),
top: '0px',
visibility: this.props.hidden ? 'hidden' : 'visible',
};
let title;
@ -210,9 +187,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
}
return (
<Velociraptor
startStyles={this.state.startStyles}
enterTransitionOpts={this.state.enterTransitionOpts} >
<NodeAnimator startStyles={this.state.startStyles}>
<MemberAvatar
member={this.props.member}
fallbackUserId={this.props.fallbackUserId}
@ -223,7 +198,7 @@ export default class ReadReceiptMarker extends React.PureComponent {
onClick={this.props.onClick}
inputRef={this._avatar}
/>
</Velociraptor>
</NodeAnimator>
);
}
}

View file

@ -79,8 +79,13 @@ export default class ReplyPreview extends React.Component {
{ _t('Replying') }
</div>
<div className="mx_ReplyPreview_header mx_ReplyPreview_cancel">
<img className="mx_filterFlipColor" src={require("../../../../res/img/cancel.svg")} width="18" height="18"
onClick={cancelQuoting} />
<img
className="mx_filterFlipColor"
src={require("../../../../res/img/cancel.svg")}
width="18"
height="18"
onClick={cancelQuoting}
/>
</div>
<div className="mx_ReplyPreview_clear" />
<EventTile

View file

@ -88,11 +88,11 @@ export default class RoomDetailRow extends React.Component {
const name = room.name || getDisplayAliasForRoom(room) || _t('Unnamed room');
const guestRead = room.worldReadable ? (
<div className="mx_RoomDirectory_perm">{ _t('World readable') }</div>
) : <div />;
<div className="mx_RoomDirectory_perm">{ _t('World readable') }</div>
) : <div />;
const guestJoin = room.guestCanJoin ? (
<div className="mx_RoomDirectory_perm">{ _t('Guests can join') }</div>
) : <div />;
<div className="mx_RoomDirectory_perm">{ _t('Guests can join') }</div>
) : <div />;
const perms = (guestRead || guestJoin) ? (<div className="mx_RoomDirectory_perms">
{ guestRead }&nbsp;

View file

@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017, 2018 Vector Creations Ltd
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-2018, 2020, 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -37,7 +35,6 @@ import { MatrixClientPeg } from "../../../MatrixClientPeg";
import GroupAvatar from "../avatars/GroupAvatar";
import ExtraTile from "./ExtraTile";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
@ -289,12 +286,11 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
// shallow-copy from the template as we need to make modifications to it
this.tagAesthetics = objectShallowClone(TAG_AESTHETICS);
this.updateDmAddRoomAction();
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
}
public componentDidMount(): void {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
this.roomStoreToken = RoomViewStore.addListener(this.onRoomViewStoreUpdate);
SpaceStore.instance.on(SUGGESTED_ROOMS, this.updateSuggestedRooms);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.updateLists);
this.customTagStoreRef = CustomRoomTagStore.addListener(this.updateLists);
@ -493,7 +489,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
isSelected={false}
displayName={g.name}
avatar={avatar}
notificationState={StaticNotificationState.forSymbol("!", NotificationColor.Red)}
notificationState={StaticNotificationState.RED_EXCLAMATION}
onClick={openGroup}
key={`temporaryGroupTile_${g.groupId}`}
/>
@ -502,62 +498,57 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
}
private renderSublists(): React.ReactElement[] {
const components: React.ReactElement[] = [];
const tagOrder = TAG_ORDER.reduce((p, c) => {
if (c === CUSTOM_TAGS_BEFORE_TAG) {
const customTags = Object.keys(this.state.sublists)
.filter(t => isCustomTag(t));
p.push(...customTags);
}
p.push(c);
return p;
}, [] as TagID[]);
// show a skeleton UI if the user is in no rooms and they are not filtering
const showSkeleton = !this.state.isNameFiltering &&
Object.values(RoomListStore.instance.unfilteredLists).every(list => !list?.length);
for (const orderedTagId of tagOrder) {
const orderedRooms = this.state.sublists[orderedTagId] || [];
let extraTiles = null;
if (orderedTagId === DefaultTagID.Invite) {
extraTiles = this.renderCommunityInvites();
} else if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
return TAG_ORDER.reduce((tags, tagId) => {
if (tagId === CUSTOM_TAGS_BEFORE_TAG) {
const customTags = Object.keys(this.state.sublists)
.filter(tagId => isCustomTag(tagId));
tags.push(...customTags);
}
tags.push(tagId);
return tags;
}, [] as TagID[])
.map(orderedTagId => {
let extraTiles = null;
if (orderedTagId === DefaultTagID.Invite) {
extraTiles = this.renderCommunityInvites();
} else if (orderedTagId === DefaultTagID.Suggested) {
extraTiles = this.renderSuggestedRooms();
}
const totalTiles = orderedRooms.length + (extraTiles ? extraTiles.length : 0);
if (totalTiles === 0 && !ALWAYS_VISIBLE_TAGS.includes(orderedTagId)) {
continue; // skip tag - not needed
}
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
: this.tagAesthetics[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
const aesthetics: ITagAesthetics = isCustomTag(orderedTagId)
? customTagAesthetics(orderedTagId)
: this.tagAesthetics[orderedTagId];
if (!aesthetics) throw new Error(`Tag ${orderedTagId} does not have aesthetics`);
components.push(<RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
onAddRoom={aesthetics.onAddRoom}
addRoomLabel={aesthetics.addRoomLabel ? _t(aesthetics.addRoomLabel) : aesthetics.addRoomLabel}
addRoomContextMenu={aesthetics.addRoomContextMenu}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
/>);
}
return components;
// The cost of mounting/unmounting this component offsets the cost
// of keeping it in the DOM and hiding it when it is not required
return <RoomSublist
key={`sublist-${orderedTagId}`}
tagId={orderedTagId}
forRooms={true}
startAsHidden={aesthetics.defaultHidden}
label={aesthetics.sectionLabelRaw ? aesthetics.sectionLabelRaw : _t(aesthetics.sectionLabel)}
onAddRoom={aesthetics.onAddRoom}
addRoomLabel={aesthetics.addRoomLabel ? _t(aesthetics.addRoomLabel) : aesthetics.addRoomLabel}
addRoomContextMenu={aesthetics.addRoomContextMenu}
isMinimized={this.props.isMinimized}
onResize={this.props.onResize}
showSkeleton={showSkeleton}
extraTiles={extraTiles}
resizeNotifier={this.props.resizeNotifier}
alwaysVisible={ALWAYS_VISIBLE_TAGS.includes(orderedTagId)}
/>
});
}
public render() {
const cli = MatrixClientPeg.get();
const userId = cli.getUserId();
let explorePrompt: JSX.Element;
if (!this.props.isMinimized) {
if (this.state.isNameFiltering) {
@ -578,21 +569,23 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
{ this.props.activeSpace ? _t("Explore rooms") : _t("Explore all public rooms") }
</AccessibleButton>
</div>;
} else if (this.props.activeSpace) {
} else if (
this.props.activeSpace?.canInvite(userId) || this.props.activeSpace?.getMyMembership() === "join"
) {
explorePrompt = <div className="mx_RoomList_explorePrompt">
<div>{ _t("Quick actions") }</div>
{ this.props.activeSpace.canInvite(MatrixClientPeg.get().getUserId()) && <AccessibleButton
{ this.props.activeSpace.canInvite(userId) && <AccessibleButton
className="mx_RoomList_explorePrompt_spaceInvite"
onClick={this.onSpaceInviteClick}
>
{_t("Invite people")}
</AccessibleButton> }
<AccessibleButton
{ this.props.activeSpace.getMyMembership() === "join" && <AccessibleButton
className="mx_RoomList_explorePrompt_spaceExplore"
onClick={this.onExplore}
>
{_t("Explore rooms")}
</AccessibleButton>
</AccessibleButton> }
</div>;
} else if (Object.values(this.state.sublists).some(list => list.length > 0)) {
const unfilteredLists = RoomListStore.instance.unfilteredLists

View file

@ -19,6 +19,7 @@ import React, {useState} from "react";
import { _t } from "../../../languageHandler";
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
import {useEventEmitter} from "../../../hooks/useEventEmitter";
import SpaceStore from "../../../stores/SpaceStore";
const RoomListNumResults: React.FC = () => {
const [count, setCount] = useState<number>(null);
@ -34,7 +35,10 @@ const RoomListNumResults: React.FC = () => {
if (typeof count !== "number") return null;
return <div className="mx_LeftPanel_roomListFilterCount">
{_t("%(count)s results", { count })}
{ SpaceStore.instance.spacePanelSpaces.length
? _t("%(count)s results in all spaces", { count })
: _t("%(count)s results", { count })
}
</div>;
};

View file

@ -44,6 +44,7 @@ import { ActionPayload } from "../../../dispatcher/payloads";
import { Enable, Resizable } from "re-resizable";
import { Direction } from "re-resizable/lib/resizer";
import { polyfillTouchEvent } from "../../../@types/polyfill";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import RoomListLayoutStore from "../../../stores/room-list/RoomListLayoutStore";
import { arrayFastClone, arrayHasOrderChange } from "../../../utils/arrays";
@ -74,7 +75,8 @@ interface IProps {
tagId: TagID;
onResize: () => void;
showSkeleton?: boolean;
alwaysVisible?: boolean;
resizeNotifier: ResizeNotifier;
extraTiles?: ReactComponentElement<typeof ExtraTile>[];
// TODO: Account for https://github.com/vector-im/element-web/issues/14179
@ -125,8 +127,6 @@ export default class RoomSublist extends React.Component<IProps, IState> {
};
// Why Object.assign() and not this.state.height? Because TypeScript says no.
this.state = Object.assign(this.state, {height: this.calculateInitialHeight()});
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
}
private calculateInitialHeight() {
@ -242,6 +242,11 @@ export default class RoomSublist extends React.Component<IProps, IState> {
return false;
}
public componentDidMount() {
this.dispatcherRef = defaultDispatcher.register(this.onAction);
RoomListStore.instance.on(LISTS_UPDATE_EVENT, this.onListsUpdated);
}
public componentWillUnmount() {
defaultDispatcher.unregister(this.dispatcherRef);
RoomListStore.instance.off(LISTS_UPDATE_EVENT, this.onListsUpdated);
@ -524,6 +529,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
tiles.push(<RoomTile
room={room}
key={`room-${room.roomId}`}
resizeNotifier={this.props.resizeNotifier}
showMessagePreview={this.layout.showPreviews}
isMinimized={this.props.isMinimized}
tag={this.props.tagId}
@ -759,6 +765,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
'mx_RoomSublist': true,
'mx_RoomSublist_hasMenuOpen': !!this.state.contextMenuPosition,
'mx_RoomSublist_minimized': this.props.isMinimized,
'mx_RoomSublist_hidden': (
!this.state.rooms.length && !this.props.extraTiles?.length && this.props.alwaysVisible !== true
),
});
let content = null;

View file

@ -1,8 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 New Vector Ltd
Copyright 2018 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015-2017, 2019-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -19,6 +17,7 @@ limitations under the License.
import React, { createRef } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import classNames from "classnames";
import { RovingTabIndexWrapper } from "../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton";
@ -51,13 +50,17 @@ import IconizedContextMenu, {
IconizedContextMenuRadio,
} from "../context_menus/IconizedContextMenu";
import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/CommunityPrototypeStore";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import { replaceableComponent } from "../../../utils/replaceableComponent";
import { getUnsentMessages } from "../../structures/RoomStatusBar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import { ResizeNotifier } from "../../../utils/ResizeNotifier";
interface IProps {
room: Room;
showMessagePreview: boolean;
isMinimized: boolean;
tag: TagID;
resizeNotifier: ResizeNotifier;
}
type PartialDOMRect = Pick<DOMRect, "left" | "bottom">;
@ -67,6 +70,7 @@ interface IState {
notificationsMenuPosition: PartialDOMRect;
generalMenuPosition: PartialDOMRect;
messagePreview?: string;
hasUnsentEvents: boolean;
}
const messagePreviewId = (roomId: string) => `mx_RoomTile_messagePreview_${roomId}`;
@ -93,26 +97,20 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
selected: ActiveRoomObserver.activeRoomId === this.props.room.roomId,
notificationsMenuPosition: null,
generalMenuPosition: null,
hasUnsentEvents: this.countUnsentEvents() > 0,
// generatePreview() will return nothing if the user has previews disabled
messagePreview: this.generatePreview(),
};
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps = EchoChamber.forRoom(this.props.room);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
this.props.room.on("Room.name", this.onRoomNameUpdate);
if (this.props.resizeNotifier) {
this.props.resizeNotifier.on("middlePanelResized", this.onResize);
}
}
private countUnsentEvents(): number {
return getUnsentMessages(this.props.room).length;
}
private onRoomNameUpdate = (room) => {
@ -123,6 +121,17 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
this.forceUpdate(); // notification state changed - update
};
private onResize = () => {
if (this.showMessagePreview && !this.state.messagePreview) {
this.setState({messagePreview: this.generatePreview()});
}
};
private onLocalEchoUpdated = (ev: MatrixEvent, room: Room) => {
if (!room?.roomId === this.props.room.roomId) return;
this.setState({hasUnsentEvents: this.countUnsentEvents() > 0});
};
private onRoomPropertyUpdate = (property: CachedRoomKey) => {
if (property === CachedRoomKey.NotificationVolume) this.onNotificationUpdate();
// else ignore - not important for this tile
@ -167,6 +176,21 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
if (this.state.selected) {
this.scrollIntoView();
}
ActiveRoomObserver.addListener(this.props.room.roomId, this.onActiveRoomUpdate);
this.dispatcherRef = defaultDispatcher.register(this.onAction);
MessagePreviewStore.instance.on(
MessagePreviewStore.getPreviewChangedEventName(this.props.room),
this.onRoomPreviewChanged,
);
this.notificationState.on(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.on(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.on("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.on(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
public componentWillUnmount() {
@ -182,8 +206,19 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
);
this.props.room.off("Room.name", this.onRoomNameUpdate);
}
if (this.props.resizeNotifier) {
this.props.resizeNotifier.off("middlePanelResized", this.onResize);
}
ActiveRoomObserver.removeListener(this.props.room.roomId, this.onActiveRoomUpdate);
defaultDispatcher.unregister(this.dispatcherRef);
this.notificationState.off(NOTIFICATION_STATE_UPDATE, this.onNotificationUpdate);
this.roomProps.off(PROPERTY_UPDATED, this.onRoomPropertyUpdate);
this.roomProps.off("Room.name", this.onRoomNameUpdate);
CommunityPrototypeStore.instance.off(
CommunityPrototypeStore.getUpdateEventName(this.props.room.roomId),
this.onCommunityUpdate,
);
MatrixClientPeg.get()?.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
}
private onAction = (payload: ActionPayload) => {
@ -549,21 +584,38 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
let badge: React.ReactNode;
if (!this.props.isMinimized) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
if (this.state.hasUnsentEvents) {
// hardcode the badge to a danger state when there's unsent messages
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={StaticNotificationState.RED_EXCLAMATION}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
} else if (this.notificationState) {
badge = (
<div className="mx_RoomTile_badgeContainer" aria-hidden="true">
<NotificationBadge
notification={this.notificationState}
forceCount={false}
roomId={this.props.room.roomId}
/>
</div>
);
}
}
let messagePreview = null;
if (this.showMessagePreview && this.state.messagePreview) {
messagePreview = (
<div className="mx_RoomTile_messagePreview" id={messagePreviewId(this.props.room.roomId)}>
<div
className="mx_RoomTile_messagePreview"
id={messagePreviewId(this.props.room.roomId)}
title={this.state.messagePreview}
>
{this.state.messagePreview}
</div>
);

View file

@ -477,6 +477,10 @@ export default class SendMessageComposer extends React.Component {
}
onAction = (payload) => {
// don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer
if (this.props.disabled) return;
switch (payload.action) {
case 'reply_to_event':
case Action.FocusComposer:
@ -502,9 +506,8 @@ export default class SendMessageComposer extends React.Component {
member.rawDisplayName : userId;
const caret = this._editorRef.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
// index is -1 if there are no parts but we only care for if this would be the part in position 0
const insertIndex = position.index > 0 ? position.index : 0;
const parts = partCreator.createMentionParts(insertIndex, displayName, userId);
// Insert suffix only if the caret is at the start of the composer
const parts = partCreator.createMentionParts(caret.offset === 0, displayName, userId);
model.transform(() => {
const addedLen = model.insert(parts, position);
return model.positionForOffset(caret.offset + addedLen, true);

View file

@ -266,25 +266,25 @@ export default class Stickerpicker extends React.Component {
width: this.popoverWidth,
}}
>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} zIndex={STICKERPICKER_Z_INDEX}>
<AppTile
app={stickerApp}
room={this.props.room}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
userWidget={true}
/>
</PersistedElement>
<PersistedElement persistKey={PERSISTED_ELEMENT_KEY} zIndex={STICKERPICKER_Z_INDEX}>
<AppTile
app={stickerApp}
room={this.props.room}
fullWidth={true}
userId={MatrixClientPeg.get().credentials.userId}
creatorUserId={stickerpickerWidget.sender || MatrixClientPeg.get().credentials.userId}
waitForIframeLoad={true}
showMenubar={true}
onEditClick={this._launchManageIntegrations}
onDeleteClick={this._removeStickerpickerWidgets}
showTitle={false}
showCancel={false}
showPopout={false}
onMinimiseClick={this._onHideStickersClick}
handleMinimisePointerEvents={true}
userWidget={true}
/>
</PersistedElement>
</div>
</div>
);

View file

@ -1,5 +1,5 @@
/*
Copyright 2019 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,9 +15,9 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import {MatrixEvent} from "matrix-js-sdk/src/models/event";
import {Room} from "matrix-js-sdk/src/models/room";
import {_t} from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import * as sdk from "../../../index";
@ -26,12 +26,24 @@ import {isValid3pidInvite} from "../../../RoomInvite";
import RoomAvatar from "../avatars/RoomAvatar";
import RoomName from "../elements/RoomName";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps {
event: MatrixEvent;
}
interface IState {
stateKey: string;
roomId: string;
displayName: string;
invited: boolean;
canKick: boolean;
senderName: string;
}
@replaceableComponent("views.rooms.ThirdPartyMemberInfo")
export default class ThirdPartyMemberInfo extends React.Component {
static propTypes = {
event: PropTypes.instanceOf(MatrixEvent).isRequired,
};
export default class ThirdPartyMemberInfo extends React.Component<IProps, IState> {
private room: Room;
constructor(props) {
super(props);
@ -124,7 +136,7 @@ export default class ThirdPartyMemberInfo extends React.Component {
}
let scopeHeader;
if (this.room.isSpaceRoom()) {
if (SettingsStore.getValue("feature_spaces") && this.room.isSpaceRoom()) {
scopeHeader = <div className="mx_RightPanel_scopeHeader">
<RoomAvatar room={this.room} height={32} width={32} />
<RoomName room={this.room} />

View file

@ -16,22 +16,29 @@ limitations under the License.
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
import {_t} from "../../../languageHandler";
import React from "react";
import {VoiceRecorder} from "../../../voice/VoiceRecorder";
import React, {ReactNode} from "react";
import {RecordingState, VoiceRecording} from "../../../voice/VoiceRecording";
import {Room} from "matrix-js-sdk/src/models/room";
import {MatrixClientPeg} from "../../../MatrixClientPeg";
import classNames from "classnames";
import LiveRecordingWaveform from "../voice_messages/LiveRecordingWaveform";
import {replaceableComponent} from "../../../utils/replaceableComponent";
import LiveRecordingClock from "../voice_messages/LiveRecordingClock";
import {VoiceRecordingStore} from "../../../stores/VoiceRecordingStore";
import {UPDATE_EVENT} from "../../../stores/AsyncStore";
import RecordingPlayback from "../voice_messages/RecordingPlayback";
import {MsgType} from "matrix-js-sdk/src/@types/event";
import Modal from "../../../Modal";
import ErrorDialog from "../dialogs/ErrorDialog";
import CallMediaHandler from "../../../CallMediaHandler";
interface IProps {
room: Room;
onRecording: (haveRecording: boolean) => void;
}
interface IState {
recorder?: VoiceRecorder;
recorder?: VoiceRecording;
recordingPhase?: RecordingState;
}
/**
@ -43,59 +50,181 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
super(props);
this.state = {
recorder: null, // not recording by default
recorder: null, // no recording started by default
};
}
private onStartStopVoiceMessage = async () => {
// TODO: @@ TravisR: We do not want to auto-send on stop.
if (this.state.recorder) {
await this.state.recorder.stop();
const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
body: "Voice message",
msgtype: "org.matrix.msc2516.voice",
url: mxc,
});
this.setState({recorder: null});
this.props.onRecording(false);
return;
public async componentWillUnmount() {
await VoiceRecordingStore.instance.disposeRecording();
}
// called by composer
public async send() {
if (!this.state.recorder) {
throw new Error("No recording started - cannot send anything");
}
const recorder = new VoiceRecorder(MatrixClientPeg.get());
await recorder.start();
this.props.onRecording(true);
this.setState({recorder});
await this.state.recorder.stop();
const mxc = await this.state.recorder.upload();
MatrixClientPeg.get().sendMessage(this.props.room.roomId, {
"body": "Voice message",
//"msgtype": "org.matrix.msc2516.voice",
"msgtype": MsgType.Audio,
"url": mxc,
"info": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
// MSC1767 experiment
"org.matrix.msc1767.text": "Voice message",
"org.matrix.msc1767.file": {
url: mxc,
name: "Voice message.ogg",
mimetype: this.state.recorder.contentType,
size: this.state.recorder.contentLength,
},
"org.matrix.msc1767.audio": {
duration: Math.round(this.state.recorder.durationSeconds * 1000),
// Events can't have floats, so we try to maintain resolution by using 1024
// as a maximum value. The waveform contains values between zero and 1, so this
// should come out largely sane.
//
// We're expecting about one data point per second of audio.
waveform: this.state.recorder.getPlayback().waveform.map(v => Math.round(v * 1024)),
},
"org.matrix.msc2516.voice": {}, // No content, this is a rendering hint
});
await this.disposeRecording();
}
private async disposeRecording() {
await VoiceRecordingStore.instance.disposeRecording();
// Reset back to no recording, which means no phase (ie: restart component entirely)
this.setState({recorder: null, recordingPhase: null});
}
private onCancel = async () => {
await this.disposeRecording();
};
private renderWaveformArea() {
if (!this.state.recorder) return null;
private onRecordStartEndClick = async () => {
if (this.state.recorder) {
await this.state.recorder.stop();
return;
}
return <div className='mx_VoiceRecordComposerTile_waveformContainer'>
// The "microphone access error" dialogs are used a lot, so let's functionify them
const accessError = () => {
Modal.createTrackedDialog('Microphone Access Error', '', ErrorDialog, {
title: _t("Unable to access your microphone"),
description: <>
<p>{_t(
"We were unable to access your microphone. Please check your browser settings and try again.",
)}</p>
</>,
});
};
// Do a sanity test to ensure we're about to grab a valid microphone reference. Things might
// change between this and recording, but at least we will have tried.
try {
const devices = await CallMediaHandler.getDevices();
if (!devices?.['audioinput']?.length) {
Modal.createTrackedDialog('No Microphone Error', '', ErrorDialog, {
title: _t("No microphone found"),
description: <>
<p>{_t(
"We didn't find a microphone on your device. Please check your settings and try again.",
)}</p>
</>,
});
return;
}
// else we probably have a device that is good enough
} catch (e) {
console.error("Error getting devices: ", e);
accessError();
return;
}
try {
const recorder = VoiceRecordingStore.instance.startRecording();
await recorder.start();
// We don't need to remove the listener: the recorder will clean that up for us.
recorder.on(UPDATE_EVENT, (ev: RecordingState) => {
if (ev === RecordingState.EndingSoon) return; // ignore this state: it has no UI purpose here
this.setState({recordingPhase: ev});
});
this.setState({recorder, recordingPhase: RecordingState.Started});
} catch (e) {
console.error("Error starting recording: ", e);
accessError();
// noinspection ES6MissingAwait - if this goes wrong we don't want it to affect the call stack
VoiceRecordingStore.instance.disposeRecording();
}
};
private renderWaveformArea(): ReactNode {
if (!this.state.recorder) return null; // no recorder means we're not recording: no waveform
if (this.state.recordingPhase !== RecordingState.Started) {
// TODO: @@ TR: Should we disable this during upload? What does a failed upload look like?
return <RecordingPlayback playback={this.state.recorder.getPlayback()} />;
}
// only other UI is the recording-in-progress UI
return <div className="mx_VoiceMessagePrimaryContainer mx_VoiceRecordComposerTile_recording">
<LiveRecordingClock recorder={this.state.recorder} />
<LiveRecordingWaveform recorder={this.state.recorder} />
</div>;
}
public render() {
const classes = classNames({
'mx_MessageComposer_button': !this.state.recorder,
'mx_MessageComposer_voiceMessage': !this.state.recorder,
'mx_VoiceRecordComposerTile_stop': !!this.state.recorder,
});
public render(): ReactNode {
let recordingInfo;
let deleteButton;
if (!this.state.recordingPhase || this.state.recordingPhase === RecordingState.Started) {
const classes = classNames({
'mx_MessageComposer_button': !this.state.recorder,
'mx_MessageComposer_voiceMessage': !this.state.recorder,
'mx_VoiceRecordComposerTile_stop': this.state.recorder?.isRecording,
});
let tooltip = _t("Record a voice message");
if (!!this.state.recorder) {
// TODO: @@ TravisR: Change to match behaviour
tooltip = _t("Stop & send recording");
let tooltip = _t("Record a voice message");
if (!!this.state.recorder) {
tooltip = _t("Stop the recording");
}
let stopOrRecordBtn = <AccessibleTooltipButton
className={classes}
onClick={this.onRecordStartEndClick}
title={tooltip}
/>;
if (this.state.recorder && !this.state.recorder?.isRecording) {
stopOrRecordBtn = null;
}
recordingInfo = stopOrRecordBtn;
}
if (this.state.recorder && this.state.recordingPhase !== RecordingState.Uploading) {
deleteButton = <AccessibleTooltipButton
className='mx_VoiceRecordComposerTile_delete'
title={_t("Delete recording")}
onClick={this.onCancel}
/>;
}
return (<>
{deleteButton}
{this.renderWaveformArea()}
<AccessibleTooltipButton
className={classes}
onClick={this.onStartStopVoiceMessage}
title={tooltip}
/>
{recordingInfo}
</>);
}
}