/* Copyright 2015-2021 The Matrix.org Foundation C.I.C. Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, 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 classNames from "classnames"; 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'; import { hasText } from "../../../TextForEvent"; import * as sdk from "../../../index"; import dis from '../../../dispatcher/dispatcher'; import SettingsStore from "../../../settings/SettingsStore"; import { Layout } from "../../../settings/Layout"; import { formatTime } from "../../../DateUtils"; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { ALL_RULE_TYPES } from "../../../mjolnir/BanList"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { E2E_STATE } from "./E2EIcon"; import { toRem } from "../../../utils/units"; import { WidgetType } from "../../../widgets/WidgetType"; import RoomAvatar from "../avatars/RoomAvatar"; import { WIDGET_LAYOUT_EVENT_TYPE } from "../../../stores/widgets/WidgetLayoutStore"; 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"; import { ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload"; import { Action } from '../../../dispatcher/actions'; const eventTileTypes = { [EventType.RoomMessage]: 'messages.MessageEvent', [EventType.Sticker]: 'messages.MessageEvent', [EventType.KeyVerificationCancel]: 'messages.MKeyVerificationConclusion', [EventType.KeyVerificationDone]: 'messages.MKeyVerificationConclusion', [EventType.CallInvite]: 'messages.TextualEvent', [EventType.CallAnswer]: 'messages.TextualEvent', [EventType.CallHangup]: 'messages.TextualEvent', [EventType.CallReject]: 'messages.TextualEvent', }; const stateEventTileTypes = { [EventType.RoomEncryption]: 'messages.EncryptionEvent', [EventType.RoomCanonicalAlias]: 'messages.TextualEvent', [EventType.RoomCreate]: 'messages.RoomCreate', [EventType.RoomMember]: 'messages.TextualEvent', [EventType.RoomName]: 'messages.TextualEvent', [EventType.RoomAvatar]: 'messages.RoomAvatarEvent', [EventType.RoomThirdPartyInvite]: 'messages.TextualEvent', [EventType.RoomHistoryVisibility]: 'messages.TextualEvent', [EventType.RoomTopic]: 'messages.TextualEvent', [EventType.RoomPowerLevels]: 'messages.TextualEvent', [EventType.RoomPinnedEvents]: 'messages.TextualEvent', [EventType.RoomServerAcl]: 'messages.TextualEvent', // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) 'im.vector.modular.widgets': 'messages.TextualEvent', [WIDGET_LAYOUT_EVENT_TYPE]: 'messages.TextualEvent', [EventType.RoomTombstone]: 'messages.TextualEvent', [EventType.RoomJoinRules]: 'messages.TextualEvent', [EventType.RoomGuestAccess]: 'messages.TextualEvent', 'm.room.related_groups': 'messages.TextualEvent', // legacy communities flair }; const stateEventSingular = new Set([ EventType.RoomEncryption, EventType.RoomCanonicalAlias, EventType.RoomCreate, EventType.RoomName, EventType.RoomAvatar, EventType.RoomHistoryVisibility, EventType.RoomTopic, EventType.RoomPowerLevels, EventType.RoomPinnedEvents, EventType.RoomServerAcl, WIDGET_LAYOUT_EVENT_TYPE, EventType.RoomTombstone, EventType.RoomJoinRules, EventType.RoomGuestAccess, 'm.room.related_groups', ]); // Add all the Mjolnir stuff to the renderer for (const evType of ALL_RULE_TYPES) { stateEventTileTypes[evType] = 'messages.TextualEvent'; } export function getHandlerTile(ev) { const type = ev.getType(); // don't show verification requests we're not involved in, // not even when showing hidden events if (type === "m.room.message") { const content = ev.getContent(); if (content && content.msgtype === "m.key.verification.request") { const client = MatrixClientPeg.get(); const me = client && client.getUserId(); if (ev.getSender() !== me && content.to !== me) { return undefined; } else { return "messages.MKeyVerificationRequest"; } } } // these events are sent by both parties during verification, but we only want to render one // tile once the verification concludes, so filter out the one from the other party. if (type === "m.key.verification.done") { const client = MatrixClientPeg.get(); const me = client && client.getUserId(); if (ev.getSender() !== me) { return undefined; } } // sometimes MKeyVerificationConclusion declines to render. Jankily decline to render and // fall back to showing hidden events, if we're viewing hidden events // XXX: This is extremely a hack. Possibly these components should have an interface for // declining to render? if (type === "m.key.verification.cancel" || type === "m.key.verification.done") { const MKeyVerificationConclusion = sdk.getComponent("messages.MKeyVerificationConclusion"); if (!MKeyVerificationConclusion.prototype._shouldRender.call(null, ev, ev.request)) { return; } } // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) if (type === "im.vector.modular.widgets") { let type = ev.getContent()['type']; if (!type) { // deleted/invalid widget - try the past widget type type = ev.getPrevContent()['type']; } if (WidgetType.JITSI.matches(type)) { return "messages.MJitsiWidgetEvent"; } } if (ev.isState()) { if (stateEventSingular.has(type) && ev.getStateKey() !== "") return undefined; return stateEventTileTypes[type]; } return eventTileTypes[type]; } const MAX_READ_AVATARS = 5; // Our component structure for EventTiles on the timeline is: // // .-EventTile------------------------------------------------. // | MemberAvatar (SenderProfile) TimeStamp | // | .-{Message,Textual}Event---------------. Read Avatars | // | | .-MFooBody-------------------. | | // | | | (only if MessageEvent) | | | // | | '----------------------------' | | // | '--------------------------------------' | // '----------------------------------------------------------' export interface IReadReceiptProps { userId: string; roomMember: RoomMember; ts: number; } export enum TileShape { Notif = "notif", FileGrid = "file_grid", Reply = "reply", ReplyPreview = "reply_preview", } 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?: TileShape; // 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; // Symbol of the root node as?: string // whether or not to always show timestamps alwaysShowTimestamps?: boolean } 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; hover: boolean; } @replaceableComponent("views.rooms.EventTile") export default class EventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; private tile = React.createRef(); private replyThread = React.createRef(); public readonly ref = createRef(); static defaultProps = { // no-op function because onHeightChanged is optional yet some sub-components assume its existence onHeightChanged: function() {}, }; static contextType = MatrixClientContext; constructor(props, context) { super(props, context); this.state = { // Whether the action bar is focused. actionBarFocused: false, // Whether all read receipts are being displayed. If not, only display // a truncation of them. allReadAvatars: false, // Whether the event's sender has been verified. verified: null, // Whether onRequestKeysClick has been called since mounting. previouslyRequestedKeys: false, // The Relations model from the JS SDK for reactions to `mxEvent` reactions: this.getReactions(), hover: false, }; // don't do RR animations until we are mounted 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; } /** * 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() { // 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; // Sanity check (should never happen, but we shouldn't explode if it does) const room = this.context.getRoom(this.props.mxEvent.getRoomId()); if (!room) return false; // Quickly check to see if the event was sent by us. If it wasn't, it won't qualify for // special read receipts. const myUserId = MatrixClientPeg.get().getUserId(); if (this.props.mxEvent.getSender() !== myUserId) return false; // 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 // those we rely on local echo giving the impression of things changing, and expect them // to be quick. const simpleSendableEvents = [ EventType.Sticker, EventType.RoomMessage, EventType.RoomMessageEncrypted, ]; if (!simpleSendableEvents.includes(this.props.mxEvent.getType() as EventType)) return false; // Default case return true; } private get shouldShowSentReceipt() { // If we're not even eligible, don't show the receipt. if (!this.isEligibleForSpecialReceipt) return false; // We only show the 'sent' receipt on the last successful event. if (!this.props.lastSuccessful) return false; // Check to make sure the sending state is appropriate. A null/undefined send status means // that the message is 'sent', so we're just double checking that it's explicitly not sent. if (this.props.eventSendStatus && this.props.eventSendStatus !== 'sent') return false; // If anyone has read the event besides us, we don't want to show a sent receipt. const receipts = this.props.readReceipts || []; const myUserId = MatrixClientPeg.get().getUserId(); if (receipts.some(r => r.userId !== myUserId)) return false; // Finally, we should show a receipt. return true; } private get shouldShowSendingReceipt() { // If we're not even eligible, don't show the receipt. 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. if (!this.props.eventSendStatus || this.props.eventSendStatus === 'sent') return false; // Default to showing - there's no other event properties/behaviours we care about at // this point. return true; } // TODO: [REACT-WARNING] Move into constructor // eslint-disable-next-line camelcase UNSAFE_componentWillMount() { this.verifyEvent(this.props.mxEvent); } componentDidMount() { 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); if (this.props.showReactions) { this.props.mxEvent.on("Event.relationsCreated", this.onReactionsCreated); } if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { client.on("Room.receipt", this.onRoomReceipt); this.isListeningForReceipts = true; } } // TODO: [REACT-WARNING] Replace with appropriate lifecycle event // eslint-disable-next-line camelcase UNSAFE_componentWillReceiveProps(nextProps) { // re-check the sender verification as outgoing events progress through // the send process. if (nextProps.eventSendStatus !== this.props.eventSendStatus) { this.verifyEvent(nextProps.mxEvent); } } shouldComponentUpdate(nextProps, nextState) { if (objectHasDiff(this.state, nextState)) { return true; } 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); if (this.props.showReactions) { 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; } } 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) { return; } // We force update because we have no state or prop changes to queue up, instead relying on // 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; } }); }; /** called when the event is decrypted after we show it. */ private onDecrypted = () => { // we need to re-verify the sending device. // (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.forceUpdate(); }; private onDeviceVerificationChanged = (userId, device) => { if (userId === this.props.mxEvent.getSender()) { this.verifyEvent(this.props.mxEvent); } }; private onUserVerificationChanged = (userId, _trustStatus) => { if (userId === this.props.mxEvent.getSender()) { this.verifyEvent(this.props.mxEvent); } }; private async verifyEvent(mxEvent) { if (!mxEvent.isEncrypted()) { return; } const encryptionInfo = this.context.getEventEncryptionInfo(mxEvent); const senderId = mxEvent.getSender(); const userTrust = this.context.checkUserTrust(senderId); if (encryptionInfo.mismatchedSender) { // something definitely wrong is going on here this.setState({ verified: E2E_STATE.WARNING, }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } if (!userTrust.isCrossSigningVerified()) { // user is not verified, so default to everything is normal this.setState({ verified: E2E_STATE.NORMAL, }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } const eventSenderTrust = encryptionInfo.sender && this.context.checkDeviceTrust( senderId, encryptionInfo.sender.deviceId, ); if (!eventSenderTrust) { this.setState({ verified: E2E_STATE.UNKNOWN, }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } if (!eventSenderTrust.isVerified()) { this.setState({ verified: E2E_STATE.WARNING, }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } if (!encryptionInfo.authenticated) { this.setState({ verified: E2E_STATE.UNAUTHENTICATED, }, this.props.onHeightChanged); // Decryption may have caused a change in size return; } this.setState({ verified: E2E_STATE.VERIFIED, }, this.props.onHeightChanged); // Decryption may have caused a change in size } private propsEqual(objA, objB) { const keysA = Object.keys(objA); const keysB = Object.keys(objB); if (keysA.length !== keysB.length) { return false; } for (let i = 0; i < keysA.length; i++) { const key = keysA[i]; if (!objB.hasOwnProperty(key)) { return false; } // need to deep-compare readReceipts if (key === 'readReceipts') { const rA = objA[key]; const rB = objB[key]; if (rA === rB) { continue; } if (!rA || !rB) { return false; } if (rA.length !== rB.length) { return false; } for (let j = 0; j < rA.length; j++) { if (rA[j].userId !== rB[j].userId) { return false; } // one has a member set and the other doesn't? if (rA[j].roomMember !== rB[j].roomMember) { return false; } } } else { if (objA[key] !== objB[key]) { return false; } } } return true; } shouldHighlight() { const actions = this.context.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent); if (!actions || !actions.tweaks) { return false; } // don't show self-highlights from another of our clients if (this.props.mxEvent.getSender() === this.context.credentials.userId) { return false; } return actions.tweaks.highlight; } toggleAllReadAvatars = () => { this.setState({ allReadAvatars: !this.state.allReadAvatars, }); }; getReadAvatars() { if (this.shouldShowSentReceipt || this.shouldShowSendingReceipt) { return ; } // return early if there are no read receipts if (!this.props.readReceipts || this.props.readReceipts.length === 0) { // We currently must include `mx_EventTile_readAvatars` in the DOM // of all events, as it is the positioned parent of the animated // read receipts. We can't let it unmount when a receipt moves // events, so for now we mount it for all events. Without it, the // animation will start from the top of the timeline (because it // lost its container). // See also https://github.com/vector-im/element-web/issues/17561 return (
); } const ReadReceiptMarker = sdk.getComponent('rooms.ReadReceiptMarker'); const avatars = []; const receiptOffset = 15; let left = 0; const receipts = this.props.readReceipts; for (let i = 0; i < receipts.length; ++i) { const receipt = receipts[i]; let hidden = true; if ((i < MAX_READ_AVATARS) || this.state.allReadAvatars) { hidden = false; } // TODO: we keep the extra read avatars in the dom to make animation simpler // we could optimise this to reduce the dom size. // If hidden, set offset equal to the offset of the final visible avatar or // else set it proportional to index left = (hidden ? MAX_READ_AVATARS - 1 : i) * -receiptOffset; const userId = receipt.userId; let readReceiptInfo; if (this.props.readReceiptMap) { readReceiptInfo = this.props.readReceiptMap[userId]; if (!readReceiptInfo) { readReceiptInfo = {}; this.props.readReceiptMap[userId] = readReceiptInfo; } } // add to the start so the most recent is on the end (ie. ends up rightmost) avatars.unshift(