Partially restore immutable event objects at the rendering layer

This is primarily to fix some extremely rare edge cases in local echo, but also restores the accuracy of some comments in the stack regarding immutable event objects (which were made mutable many years ago).

This shouldn't have any impact on the daily usage of the app, only adding a measured 0ms of latency to the stack.
pull/21833/head
Travis Ralston 2021-06-07 20:19:16 -06:00
parent 416563d919
commit ea46df0d48
2 changed files with 76 additions and 49 deletions

View File

@ -262,6 +262,7 @@ export default class TextualBody extends React.Component {
// exploit that events are immutable :) // exploit that events are immutable :)
return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() || return (nextProps.mxEvent.getId() !== this.props.mxEvent.getId() ||
nextProps.mxEvent !== this.props.mxEvent ||
nextProps.highlights !== this.props.highlights || nextProps.highlights !== this.props.highlights ||
nextProps.replacingEventId !== this.props.replacingEventId || nextProps.replacingEventId !== this.props.replacingEventId ||
nextProps.highlightLink !== this.props.highlightLink || nextProps.highlightLink !== this.props.highlightLink ||

View File

@ -298,6 +298,9 @@ interface IState {
// The Relations model from the JS SDK for reactions to `mxEvent` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions: Relations; reactions: Relations;
// Our snapshotted/local copy of the props.mxEvent, for local echo reasons
mxEvent: MatrixEvent;
hover: boolean; hover: boolean;
} }
@ -332,6 +335,8 @@ export default class EventTile extends React.Component<IProps, IState> {
// The Relations model from the JS SDK for reactions to `mxEvent` // The Relations model from the JS SDK for reactions to `mxEvent`
reactions: this.getReactions(), reactions: this.getReactions(),
mxEvent: this.mxEvent.getSnapshotCopy(), // snapshot up front to verify it all works
hover: false, hover: false,
}; };
@ -348,6 +353,10 @@ export default class EventTile extends React.Component<IProps, IState> {
this.ref = React.createRef(); this.ref = React.createRef();
} }
private get mxEvent(): MatrixEvent {
return this.state?.mxEvent ?? this.props.mxEvent;
}
/** /**
* When true, the tile qualifies for some sort of special read receipt. This could be a 'sending' * When true, the tile qualifies for some sort of special read receipt. This could be a 'sending'
* or 'sent' receipt, for example. * or 'sent' receipt, for example.
@ -356,16 +365,16 @@ export default class EventTile extends React.Component<IProps, IState> {
private get isEligibleForSpecialReceipt() { private get isEligibleForSpecialReceipt() {
// First, if there are other read receipts then just short-circuit this. // 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.readReceipts && this.props.readReceipts.length > 0) return false;
if (!this.props.mxEvent) return false; if (!this.mxEvent) return false;
// Sanity check (should never happen, but we shouldn't explode if it does) // Sanity check (should never happen, but we shouldn't explode if it does)
const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const room = this.context.getRoom(this.mxEvent.getRoomId());
if (!room) return false; if (!room) return false;
// Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for // Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for
// special read receipts. // special read receipts.
const myUserId = MatrixClientPeg.get().getUserId(); const myUserId = MatrixClientPeg.get().getUserId();
if (this.props.mxEvent.getSender() !== myUserId) return false; if (this.mxEvent.getSender() !== myUserId) return false;
// Finally, determine if the type is relevant to the user. This notably excludes state // Finally, determine if the type is relevant to the user. This notably excludes state
// events and pretty much anything that can't be sent by the composer as a message. For // events and pretty much anything that can't be sent by the composer as a message. For
@ -376,7 +385,7 @@ export default class EventTile extends React.Component<IProps, IState> {
EventType.RoomMessage, EventType.RoomMessage,
EventType.RoomMessageEncrypted, EventType.RoomMessageEncrypted,
]; ];
if (!simpleSendableEvents.includes(this.props.mxEvent.getType())) return false; if (!simpleSendableEvents.includes(this.mxEvent.getType())) return false;
// Default case // Default case
return true; return true;
@ -418,7 +427,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// TODO: [REACT-WARNING] Move into constructor // TODO: [REACT-WARNING] Move into constructor
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
UNSAFE_componentWillMount() { UNSAFE_componentWillMount() {
this.verifyEvent(this.props.mxEvent); this.verifyEvent(this.mxEvent);
} }
componentDidMount() { componentDidMount() {
@ -448,11 +457,21 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
// If the echo changed meaningfully, update.
if (!this.state.mxEvent?.isEquivalentTo(nextProps.mxEvent)) {
return true;
}
if (objectHasDiff(this.state, nextState)) { if (objectHasDiff(this.state, nextState)) {
return true; return true;
} }
return !this.propsEqual(this.props, nextProps); if (!this.propsEqual(this.props, nextProps)) {
return true;
}
// Always assume there's no significant change.
return false;
} }
componentWillUnmount() { componentWillUnmount() {
@ -473,11 +492,18 @@ export default class EventTile extends React.Component<IProps, IState> {
this.context.on("Room.receipt", this.onRoomReceipt); this.context.on("Room.receipt", this.onRoomReceipt);
this.isListeningForReceipts = true; this.isListeningForReceipts = true;
} }
// Update the state again if the snapshot needs updating. Note that this will fire
// a second state update to re-render child components, which ultimately calls didUpdate
// again, so we break that loop with a reference check first (faster than comparing events).
if (this.state.mxEvent === prevState.mxEvent && !this.state?.mxEvent.isEquivalentTo(this.props.mxEvent)) {
this.setState({mxEvent: this.props.mxEvent.getSnapshotCopy()});
}
} }
private onRoomReceipt = (ev, room) => { private onRoomReceipt = (ev, room) => {
// ignore events for other rooms // ignore events for other rooms
const tileRoom = MatrixClientPeg.get().getRoom(this.props.mxEvent.getRoomId()); const tileRoom = MatrixClientPeg.get().getRoom(this.mxEvent.getRoomId());
if (room !== tileRoom) return; if (room !== tileRoom) return;
if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) { if (!this.shouldShowSentReceipt && !this.shouldShowSendingReceipt && !this.isListeningForReceipts) {
@ -501,19 +527,19 @@ export default class EventTile extends React.Component<IProps, IState> {
// we need to re-verify the sending device. // 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) // has caused a change in size of the event tile)
this.verifyEvent(this.props.mxEvent); this.verifyEvent(this.mxEvent);
this.forceUpdate(); this.forceUpdate();
}; };
private onDeviceVerificationChanged = (userId, device) => { private onDeviceVerificationChanged = (userId, device) => {
if (userId === this.props.mxEvent.getSender()) { if (userId === this.mxEvent.getSender()) {
this.verifyEvent(this.props.mxEvent); this.verifyEvent(this.mxEvent);
} }
}; };
private onUserVerificationChanged = (userId, _trustStatus) => { private onUserVerificationChanged = (userId, _trustStatus) => {
if (userId === this.props.mxEvent.getSender()) { if (userId === this.mxEvent.getSender()) {
this.verifyEvent(this.props.mxEvent); this.verifyEvent(this.mxEvent);
} }
}; };
@ -620,11 +646,11 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
shouldHighlight() { shouldHighlight() {
const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent); const actions = this.context.getPushActionsForEvent(this.mxEvent.replacingEvent() || this.mxEvent);
if (!actions || !actions.tweaks) { return false; } if (!actions || !actions.tweaks) { return false; }
// don't show self-highlights from another of our clients // don't show self-highlights from another of our clients
if (this.props.mxEvent.getSender() === this.context.credentials.userId) { if (this.mxEvent.getSender() === this.context.credentials.userId) {
return false; return false;
} }
@ -639,7 +665,7 @@ export default class EventTile extends React.Component<IProps, IState> {
getReadAvatars() { getReadAvatars() {
if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) {
return <SentReceipt messageState={this.props.mxEvent.getAssociatedStatus()} />; return <SentReceipt messageState={this.mxEvent.getAssociatedStatus()} />;
} }
// return early if there are no read receipts // return early if there are no read receipts
@ -726,7 +752,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
onSenderProfileClick = event => { onSenderProfileClick = event => {
const mxEvent = this.props.mxEvent; const mxEvent = this.mxEvent;
dis.dispatch({ dis.dispatch({
action: 'insert_mention', action: 'insert_mention',
user_id: mxEvent.getSender(), user_id: mxEvent.getSender(),
@ -743,7 +769,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// Cancel any outgoing key request for this event and resend it. If a response // Cancel any outgoing key request for this event and resend it. If a response
// is received for the request with the required keys, the event could be // is received for the request with the required keys, the event could be
// decrypted successfully. // decrypted successfully.
this.context.cancelAndResendEventRoomKeyRequest(this.props.mxEvent); this.context.cancelAndResendEventRoomKeyRequest(this.mxEvent);
}; };
onPermalinkClicked = e => { onPermalinkClicked = e => {
@ -752,14 +778,14 @@ export default class EventTile extends React.Component<IProps, IState> {
e.preventDefault(); e.preventDefault();
dis.dispatch({ dis.dispatch({
action: 'view_room', action: 'view_room',
event_id: this.props.mxEvent.getId(), event_id: this.mxEvent.getId(),
highlighted: true, highlighted: true,
room_id: this.props.mxEvent.getRoomId(), room_id: this.mxEvent.getRoomId(),
}); });
}; };
private renderE2EPadlock() { private renderE2EPadlock() {
const ev = this.props.mxEvent; const ev = this.mxEvent;
// event could not be decrypted // event could not be decrypted
if (ev.getContent().msgtype === 'm.bad.encrypted') { if (ev.getContent().msgtype === 'm.bad.encrypted') {
@ -818,7 +844,7 @@ export default class EventTile extends React.Component<IProps, IState> {
) { ) {
return null; return null;
} }
const eventId = this.props.mxEvent.getId(); const eventId = this.mxEvent.getId();
return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction"); return this.props.getRelationsForEvent(eventId, "m.annotation", "m.reaction");
}; };
@ -837,13 +863,13 @@ export default class EventTile extends React.Component<IProps, IState> {
const SenderProfile = sdk.getComponent('messages.SenderProfile'); const SenderProfile = sdk.getComponent('messages.SenderProfile');
const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('avatars.MemberAvatar');
//console.info("EventTile showUrlPreview for %s is %s", this.props.mxEvent.getId(), this.props.showUrlPreview); //console.info("EventTile showUrlPreview for %s is %s", this.mxEvent.getId(), this.props.showUrlPreview);
const content = this.props.mxEvent.getContent(); const content = this.mxEvent.getContent();
const msgtype = content.msgtype; const msgtype = content.msgtype;
const eventType = this.props.mxEvent.getType(); const eventType = this.mxEvent.getType();
let tileHandler = getHandlerTile(this.props.mxEvent); let tileHandler = getHandlerTile(this.mxEvent);
// Info messages are basically information about commands processed on a room // Info messages are basically information about commands processed on a room
const isBubbleMessage = eventType.startsWith("m.key.verification") || const isBubbleMessage = eventType.startsWith("m.key.verification") ||
@ -860,7 +886,7 @@ export default class EventTile extends React.Component<IProps, IState> {
// source tile when there's no regular tile for an event and also for // source tile when there's no regular tile for an event and also for
// replace relations (which otherwise would display as a confusing // replace relations (which otherwise would display as a confusing
// duplicate of the thing they are replacing). // duplicate of the thing they are replacing).
if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.props.mxEvent)) { if (SettingsStore.getValue("showHiddenEventsInTimeline") && !haveTileForEvent(this.mxEvent)) {
tileHandler = "messages.ViewSourceEvent"; tileHandler = "messages.ViewSourceEvent";
// Reuse info message avatar and sender profile styling // Reuse info message avatar and sender profile styling
isInfoMessage = true; isInfoMessage = true;
@ -879,8 +905,8 @@ export default class EventTile extends React.Component<IProps, IState> {
const EventTileType = sdk.getComponent(tileHandler); const EventTileType = sdk.getComponent(tileHandler);
const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1); const isSending = (['sending', 'queued', 'encrypting'].indexOf(this.props.eventSendStatus) !== -1);
const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; const isRedacted = isMessageEvent(this.mxEvent) && this.props.isRedacted;
const isEncryptionFailure = this.props.mxEvent.isDecryptionFailure(); const isEncryptionFailure = this.mxEvent.isDecryptionFailure();
const isEditing = !!this.props.editState; const isEditing = !!this.props.editState;
const classes = classNames({ const classes = classNames({
@ -910,14 +936,14 @@ export default class EventTile extends React.Component<IProps, IState> {
let permalink = "#"; let permalink = "#";
if (this.props.permalinkCreator) { if (this.props.permalinkCreator) {
permalink = this.props.permalinkCreator.forEvent(this.props.mxEvent.getId()); permalink = this.props.permalinkCreator.forEvent(this.mxEvent.getId());
} }
// we can't use local echoes as scroll tokens, because their event IDs change. // we can't use local echoes as scroll tokens, because their event IDs change.
// Local echos have a send "status". // Local echos have a send "status".
const scrollToken = this.props.mxEvent.status const scrollToken = this.mxEvent.status
? undefined ? undefined
: this.props.mxEvent.getId(); : this.mxEvent.getId();
let avatar; let avatar;
let sender; let sender;
@ -947,15 +973,15 @@ export default class EventTile extends React.Component<IProps, IState> {
needsSenderProfile = true; needsSenderProfile = true;
} }
if (this.props.mxEvent.sender && avatarSize) { if (this.mxEvent.sender && avatarSize) {
let member; let member;
// set member to receiver (target) if it is a 3PID invite // set member to receiver (target) if it is a 3PID invite
// so that the correct avatar is shown as the text is // so that the correct avatar is shown as the text is
// `$target accepted the invitation for $email` // `$target accepted the invitation for $email`
if (this.props.mxEvent.getContent().third_party_invite) { if (this.mxEvent.getContent().third_party_invite) {
member = this.props.mxEvent.target; member = this.mxEvent.target;
} else { } else {
member = this.props.mxEvent.sender; member = this.mxEvent.sender;
} }
avatar = ( avatar = (
<div className="mx_EventTile_avatar"> <div className="mx_EventTile_avatar">
@ -970,17 +996,17 @@ export default class EventTile extends React.Component<IProps, IState> {
if (needsSenderProfile) { if (needsSenderProfile) {
if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') { if (!this.props.tileShape || this.props.tileShape === 'reply' || this.props.tileShape === 'reply_preview') {
sender = <SenderProfile onClick={this.onSenderProfileClick} sender = <SenderProfile onClick={this.onSenderProfileClick}
mxEvent={this.props.mxEvent} mxEvent={this.mxEvent}
enableFlair={this.props.enableFlair} enableFlair={this.props.enableFlair}
/>; />;
} else { } else {
sender = <SenderProfile mxEvent={this.props.mxEvent} enableFlair={this.props.enableFlair} />; sender = <SenderProfile mxEvent={this.mxEvent} enableFlair={this.props.enableFlair} />;
} }
} }
const MessageActionBar = sdk.getComponent('messages.MessageActionBar'); const MessageActionBar = sdk.getComponent('messages.MessageActionBar');
const actionBar = !isEditing ? <MessageActionBar const actionBar = !isEditing ? <MessageActionBar
mxEvent={this.props.mxEvent} mxEvent={this.mxEvent}
reactions={this.state.reactions} reactions={this.state.reactions}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
getTile={this.getTile} getTile={this.getTile}
@ -988,10 +1014,10 @@ export default class EventTile extends React.Component<IProps, IState> {
onFocusChange={this.onActionBarFocusChange} onFocusChange={this.onActionBarFocusChange}
/> : undefined; /> : undefined;
const showTimestamp = this.props.mxEvent.getTs() && const showTimestamp = this.mxEvent.getTs() &&
(this.props.alwaysShowTimestamps || this.props.last || this.state.hover || this.state.actionBarFocused); (this.props.alwaysShowTimestamps || this.props.last || this.state.hover || this.state.actionBarFocused);
const timestamp = showTimestamp ? const timestamp = showTimestamp ?
<MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.props.mxEvent.getTs()} /> : null; <MessageTimestamp showTwelveHour={this.props.isTwelveHour} ts={this.mxEvent.getTs()} /> : null;
const keyRequestHelpText = const keyRequestHelpText =
<div className="mx_EventTile_keyRequestInfo_tooltip_contents"> <div className="mx_EventTile_keyRequestInfo_tooltip_contents">
@ -1031,7 +1057,7 @@ export default class EventTile extends React.Component<IProps, IState> {
if (!isRedacted) { if (!isRedacted) {
const ReactionsRow = sdk.getComponent('messages.ReactionsRow'); const ReactionsRow = sdk.getComponent('messages.ReactionsRow');
reactionsRow = <ReactionsRow reactionsRow = <ReactionsRow
mxEvent={this.props.mxEvent} mxEvent={this.mxEvent}
reactions={this.state.reactions} reactions={this.state.reactions}
/>; />;
} }
@ -1039,7 +1065,7 @@ export default class EventTile extends React.Component<IProps, IState> {
const linkedTimestamp = <a const linkedTimestamp = <a
href={permalink} href={permalink}
onClick={this.onPermalinkClicked} onClick={this.onPermalinkClicked}
aria-label={formatTime(new Date(this.props.mxEvent.getTs()), this.props.isTwelveHour)} aria-label={formatTime(new Date(this.mxEvent.getTs()), this.props.isTwelveHour)}
> >
{ timestamp } { timestamp }
</a>; </a>;
@ -1058,7 +1084,7 @@ export default class EventTile extends React.Component<IProps, IState> {
switch (this.props.tileShape) { switch (this.props.tileShape) {
case 'notif': { case 'notif': {
const room = this.context.getRoom(this.props.mxEvent.getRoomId()); const room = this.context.getRoom(this.mxEvent.getRoomId());
return React.createElement(this.props.as || "li", { return React.createElement(this.props.as || "li", {
"className": classes, "className": classes,
"aria-live": ariaLive, "aria-live": ariaLive,
@ -1080,7 +1106,7 @@ export default class EventTile extends React.Component<IProps, IState> {
</div>, </div>,
<div className="mx_EventTile_line" key="mx_EventTile_line"> <div className="mx_EventTile_line" key="mx_EventTile_line">
<EventTileType ref={this.tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
@ -1098,7 +1124,7 @@ export default class EventTile extends React.Component<IProps, IState> {
}, [ }, [
<div className="mx_EventTile_line" key="mx_EventTile_line"> <div className="mx_EventTile_line" key="mx_EventTile_line">
<EventTileType ref={this.tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
showUrlPreview={this.props.showUrlPreview} showUrlPreview={this.props.showUrlPreview}
@ -1125,7 +1151,7 @@ export default class EventTile extends React.Component<IProps, IState> {
let thread; let thread;
if (this.props.tileShape === 'reply_preview') { if (this.props.tileShape === 'reply_preview') {
thread = ReplyThread.makeThread( thread = ReplyThread.makeThread(
this.props.mxEvent, this.mxEvent,
this.props.onHeightChanged, this.props.onHeightChanged,
this.props.permalinkCreator, this.props.permalinkCreator,
this.replyThread, this.replyThread,
@ -1148,7 +1174,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ groupPadlock } { groupPadlock }
{ thread } { thread }
<EventTileType ref={this.tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.mxEvent}
highlights={this.props.highlights} highlights={this.props.highlights}
highlightLink={this.props.highlightLink} highlightLink={this.props.highlightLink}
onHeightChanged={this.props.onHeightChanged} onHeightChanged={this.props.onHeightChanged}
@ -1160,7 +1186,7 @@ export default class EventTile extends React.Component<IProps, IState> {
} }
default: { default: {
const thread = ReplyThread.makeThread( const thread = ReplyThread.makeThread(
this.props.mxEvent, this.mxEvent,
this.props.onHeightChanged, this.props.onHeightChanged,
this.props.permalinkCreator, this.props.permalinkCreator,
this.replyThread, this.replyThread,
@ -1188,7 +1214,7 @@ export default class EventTile extends React.Component<IProps, IState> {
{ groupPadlock } { groupPadlock }
{ thread } { thread }
<EventTileType ref={this.tile} <EventTileType ref={this.tile}
mxEvent={this.props.mxEvent} mxEvent={this.mxEvent}
replacingEventId={this.props.replacingEventId} replacingEventId={this.props.replacingEventId}
editState={this.props.editState} editState={this.props.editState}
highlights={this.props.highlights} highlights={this.props.highlights}