Merge branch 'develop' into edit-slashcommands
This commit is contained in:
commit
12657f0fbc
336 changed files with 11521 additions and 4060 deletions
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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>;
|
|
@ -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
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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,
|
||||
};
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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} />
|
|
@ -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}
|
||||
</>);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue