diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 52c8fd3ad6..87af26a2a7 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -18,7 +18,6 @@ import React, { } from "react"; import { type EventStatus, - EventType, type MatrixEvent, MatrixEventEvent, type Relations, @@ -29,10 +28,8 @@ import { ThreadEvent, } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; -import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; import { uniqueId } from "lodash"; -import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { useCreateAutoDisposedViewModel, PinnedMessageBadge, TileErrorView } from "@element-hq/web-shared-components"; +import { useCreateAutoDisposedViewModel, TileErrorView } from "@element-hq/web-shared-components"; import ReplyChain from "../elements/ReplyChain"; import { _t } from "../../../languageHandler"; @@ -57,7 +54,12 @@ import { copyPlaintext } from "../../../utils/strings"; import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker"; import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import PosthogTrackers from "../../../PosthogTrackers"; -import { haveRendererForEvent, isMessageEvent, renderTile } from "../../../events/EventTileFactory"; +import { + haveRendererForEvent, + isMessageEvent, + renderTile, + type EventTileTypeProps, +} from "../../../events/EventTileFactory"; import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge"; import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; @@ -65,14 +67,13 @@ import PinningUtils from "../../../utils/PinningUtils"; import { ActionBarAdapter } from "./EventTile/ActionBarAdapter"; import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter"; -import { EventPreviewAdapter } from "./EventTile/EventPreviewAdapter"; -import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter"; -import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter"; import { ReceiptAdapter } from "./EventTile/ReceiptAdapter"; import { EventTileAvatarAdapter, EventTileSenderAdapter } from "./EventTile/SenderIdentityAdapter"; import { ThreadListActionBarAdapter } from "./EventTile/ThreadListActionBarAdapter"; -import { ThreadMessagePreviewAdapter } from "./EventTile/ThreadMessagePreviewAdapter"; -import { ThreadSummaryAdapter } from "./EventTile/ThreadSummaryAdapter"; +import { EventTileFooter } from "./EventTile/EventTileFooter"; +import { EventTilePreviewBody } from "./EventTile/EventTilePreviewBody"; +import { EventTileThreadInfo, EventTileThreadPanelSummary } from "./EventTile/EventTileThreadInfo"; +import { EventTileTimestampSlot } from "./EventTile/EventTileTimestampSlot"; import { EventTileViewModel, type EventTileViewModelProps, @@ -107,8 +108,9 @@ import { } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; import { TileErrorViewModel } from "../../../viewmodels/message-body/TileErrorViewModel"; import { useSettingValue } from "../../../hooks/useSettings"; -import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel"; +import { shouldHighlightEventTile } from "../../../viewmodels/room/timeline/event-tile/EventTileHighlightState"; +import { shouldHideEventTile } from "../../../viewmodels/room/timeline/event-tile/EventTileVisibilityState"; /** Relation lookup type retained for EventTile consumers. */ export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; @@ -124,134 +126,136 @@ export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/eve // | '--------------------------------------' | // '----------------------------------------------------------' +/** Read receipt entry rendered beside an EventTile. */ export interface IReadReceiptProps { + /** User ID that owns the receipt. */ userId: string; + /** Room member profile for the receipt user, if available. */ roomMember: RoomMember | null; + /** Receipt timestamp in milliseconds. */ ts: number; } +/** Operations exposed by specialized event tile body renderers. */ export interface IEventTileOps { + /** Whether the body renderer is currently hiding a widget. */ isWidgetHidden(): boolean; + /** Reveal a hidden widget in the body renderer. */ unhideWidget(): void; } +/** Ref surface exposed by concrete event tile body components. */ export interface IEventTileType extends React.Component { + /** Optional body-specific operations for context menu actions. */ getEventTileOps?(): IEventTileOps; + /** Media helper owned by the body renderer, when the event has media. */ getMediaHelper(): MediaEventHelper | undefined; } +/** Props used to render an event in a room timeline surface. */ export interface EventTileProps { - // the MatrixEvent to show + /** Matrix event to render. */ 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. + /** + * Whether the event is redacted. + * This mirrors `mxEvent.isRedacted()` because `mxEvent` may retain object identity across updates. + */ isRedacted?: boolean; - // true if this is a continuation of the previous event (which has the - // effect of not showing another avatar/displayname + /** Whether this event continues the previous sender block. */ continuation?: boolean; - // true if this is the last event in the timeline (which has the effect - // of always showing the timestamp) + /** Whether this is the last event in the timeline. */ last?: boolean; - // true if the event is the last event in a section (adds a css class for - // targeting) + /** Whether this is the last event in a timeline section. */ lastInSection?: boolean; - // True if the event is the last successful (sent) event. + /** Whether this is the most recent successfully sent event. */ lastSuccessful?: boolean; - // true if this is search context (which has the effect of greying out - // the text + /** Whether this tile is rendered with search-context styling. */ contextual?: boolean; - // a list of words to highlight, ordered by longest first + /** Words to highlight in the event body, ordered longest first. */ highlights?: string[]; - // link URL for the highlights + /** Link URL used for highlighted terms. */ highlightLink?: string; - // should show URL previews for this event + /** Whether URL previews should be shown for this event. */ showUrlPreview?: boolean; - // is this the focused event + /** Whether this is the currently selected event. */ isSelectedEvent?: boolean; + /** Resize observer used by the parent timeline, if any. */ resizeObserver?: ResizeObserver; - // a list of read-receipts we should show. Each object has a 'roomMember' and 'ts'. + /** Read receipts to show for this event. */ 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 + /** Opaque read receipt animation state keyed by user ID. */ readReceiptMap?: { [userId: string]: IReadReceiptPosition }; - // 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. + /** Returns whether the parent panel is unmounting and tile work should be skipped. */ checkUnmounting?: () => boolean; - // the status of this event - ie, mxEvent.status. Denormalised to here so - // that we can tell when it changes. + /** Denormalized `mxEvent.status` so status changes can trigger tile updates. */ eventSendStatus?: EventStatus; + /** Whether this tile is being rendered for export. */ forExport?: boolean; - // show twelve hour timestamps + /** Whether timestamps should use twelve-hour formatting. */ isTwelveHour?: boolean; - // helper function to access relations for this event + /** Helper used to access relations for this event. */ getRelationsForEvent?: GetRelationsForEvent; - // whether to show reactions for this event + /** Whether reactions should be shown for this event. */ showReactions?: boolean; - // which layout to use + /** Timeline layout used by this tile. */ layout?: Layout; - // whether or not to show read receipts + /** Whether read receipts should be shown for this event. */ 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. + /** Editor state used while editing and across remote echo remounts. */ editState?: EditorStateTransfer; - // Event ID of the event replacing the content of this event, if any + /** Event ID replacing this event's content, if any. */ replacingEventId?: string; - // Helper to build permalinks for the room + /** Helper used to build room and event permalinks. */ permalinkCreator?: RoomPermalinkCreator; - // LegacyCallEventGrouper for this event + /** Legacy call event grouping state for this event. */ callEventGrouper?: LegacyCallEventGrouper; - // Symbol of the root node + /** Element type used for the root node. */ as?: string; - // whether or not to always show timestamps + /** Whether timestamps should always be visible. */ alwaysShowTimestamps?: boolean; - // whether or not to display the sender + /** Whether sender identity should be hidden. */ hideSender?: boolean; - // whether or not to display thread info + /** Whether thread information should be shown. */ showThreadInfo?: boolean; - // if specified and `true`, the message is being - // hidden for moderation from other users but is - // displayed to the current user either because they're - // the author or they are a moderator + /** Whether the current user can see a message hidden from other users for moderation. */ isSeeingThroughMessageHiddenForModeration?: boolean; - // The following properties are used by EventTilePreview to disable tab indexes within the event tile + /** Whether the timestamp should be hidden for preview rendering. */ hideTimestamp?: boolean; + /** Whether interactive controls inside the tile should be inhibited. */ inhibitInteraction?: boolean; + /** Ref for imperative access to the unwrapped tile instance. */ ref?: Ref; } @@ -274,7 +278,13 @@ interface EventTileRenderInputs { isOwnEvent: boolean; } -// MUST be rendered within a RoomContext with a set timelineRenderingType +interface EventTileRootRenderState { + tileClasses: string; + tileAriaLive?: "off"; + scrollToken?: string; +} + +/** EventTile implementation rendered inside a RoomContext with `timelineRenderingType` set. */ export class UnwrappedEventTile extends React.Component { private suppressReadReceiptAnimation: boolean; private isListeningForReceipts: boolean; @@ -467,51 +477,6 @@ export class UnwrappedEventTile extends React.Component }); } - private renderThreadPanelSummary(threadState: EventTileThreadState): JSX.Element | null { - if (!threadState.shouldShowThreadPanelSummary || !threadState.thread) { - return null; - } - - return ( -
- - {threadState.thread.length} - -
- ); - } - - private renderThreadInfo(threadState: EventTileThreadState): React.ReactNode { - if (threadState.shouldShowThreadSummary && threadState.thread) { - return ( - - ); - } - - if (threadState.searchThreadInfo.kind === "link") { - return ( - - - {_t("timeline|thread_info_basic")} - - ); - } - - if (threadState.searchThreadInfo.kind === "text") { - return ( -

- - {_t("timeline|thread_info_basic")} -

- ); - } - } - private readonly onViewInRoomClick = (_anchor: HTMLElement | null): void => { dis.dispatch({ action: Action.ViewRoom, @@ -610,42 +575,6 @@ export class UnwrappedEventTile extends React.Component return true; } - /** - * Determine whether an event should be highlighted - * For edited events, if a previous version of the event was highlighted - * the event should remain highlighted as the user may have been notified - * (Clearer explanation of why an event is highlighted is planned - - * https://github.com/vector-im/element-web/issues/24927) - * @returns boolean - */ - private shouldHighlight(): boolean { - if (this.props.forExport) return false; - if (this.context.timelineRenderingType === TimelineRenderingType.Notification) return false; - if (this.context.timelineRenderingType === TimelineRenderingType.ThreadsList) return false; - - if (this.props.isRedacted) return false; - - // This event is a room mention but we don't want the call tile to have a highlight. - if (this.props.mxEvent.getType() === EventType.RTCNotification) return false; - - const cli = MatrixClientPeg.safeGet(); - const actions = cli.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent); - // get the actions for the previous version of the event too if it is an edit - const previousActions = this.props.mxEvent.replacingEvent() - ? cli.getPushActionsForEvent(this.props.mxEvent) - : undefined; - if (!actions?.tweaks && !previousActions?.tweaks) { - return false; - } - - // don't show self-highlights from another of our clients - if (this.props.mxEvent.getSender() === cli.credentials.userId) { - return false; - } - - return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight); - } - private readonly onSenderProfileClick = (): void => { dis.dispatch({ action: Action.ComposerInsert, @@ -821,18 +750,6 @@ export class UnwrappedEventTile extends React.Component }); }; - /** - * In some cases we can't use shouldHideEvent() since whether or not we hide - * an event depends on other things that the event itself - * @returns {boolean} true if event should be hidden - */ - private shouldHideEvent(): boolean { - // If the call was replaced we don't render anything since we render the other call - if (this.props.callEventGrouper?.hangupReason === CallErrorCode.Replaced) return true; - - return false; - } - private createMessageTimestampProps(ts: number): MessageTimestampViewModelProps { return { showRelative: this.context.timelineRenderingType === TimelineRenderingType.ThreadsList, @@ -861,12 +778,57 @@ export class UnwrappedEventTile extends React.Component return "#"; } + private createRootAttributes({ + tileClasses, + tileAriaLive, + scrollToken, + }: EventTileRootRenderState): Record { + return { + "className": tileClasses, + "aria-live": tileAriaLive, + "aria-atomic": true, + "data-scroll-tokens": scrollToken, + }; + } + + private createInteractiveRootAttributes(rootRenderState: EventTileRootRenderState): Record { + return { + ...this.createRootAttributes(rootRenderState), + ref: this.ref, + onMouseEnter: this.onMouseEnter, + onMouseLeave: this.onMouseLeave, + onFocus: this.onFocusWithin, + onBlur: this.onBlurWithin, + }; + } + + private createRenderTileProps({ + replacingEventId, + isSeeingThroughMessageHiddenForModeration, + permalinkCreator = this.props.permalinkCreator, + }: { + replacingEventId?: string; + isSeeingThroughMessageHiddenForModeration?: boolean; + permalinkCreator?: RoomPermalinkCreator; + }): EventTileTypeProps { + return { + ...this.props, + ref: this.tile, + ...(replacingEventId === undefined ? {} : { replacingEventId }), + isSeeingThroughMessageHiddenForModeration, + highlights: this.props.highlights, + highlightLink: this.props.highlightLink, + permalinkCreator, + showHiddenEvents: this.context.showHiddenEvents, + }; + } + private createRenderInputs( displayInfo = getEventDisplayInfo( MatrixClientPeg.safeGet(), this.props.mxEvent, this.context.showHiddenEvents, - this.shouldHideEvent(), + shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }), ), ): EventTileRenderInputs { const isRedacted = isMessageEvent(this.props.mxEvent) && this.props.isRedacted; @@ -910,7 +872,13 @@ export class UnwrappedEventTile extends React.Component isInfoMessage: displayInfo.isInfoMessage, noBubbleEvent: displayInfo.noBubbleEvent, isTwelveHour: this.props.isTwelveHour, - isHighlighted: this.shouldHighlight(), + isHighlighted: shouldHighlightEventTile({ + cli: MatrixClientPeg.safeGet(), + mxEvent: this.props.mxEvent, + timelineRenderingType: this.context.timelineRenderingType, + forExport: this.props.forExport, + isRedacted: this.props.isRedacted, + }), isSelected: this.props.isSelectedEvent || !!this.state.interaction.contextMenu, isLast: this.props.last, isLastInSection: this.props.lastInSection, @@ -973,7 +941,7 @@ export class UnwrappedEventTile extends React.Component MatrixClientPeg.safeGet(), this.props.mxEvent, this.context.showHiddenEvents, - this.shouldHideEvent(), + shouldHideEventTile({ callEventGrouper: this.props.callEventGrouper }), ); const { hasRenderer, isSeeingThroughMessageHiddenForModeration } = displayInfo; const { isQuoteExpanded } = this.state; @@ -992,7 +960,8 @@ export class UnwrappedEventTile extends React.Component const renderInputs = this.createRenderInputs(displayInfo); const { hasPinnedMessageBadge, hasReactionsRow, threadState, isOwnEvent } = renderInputs; - const eventTileRenderState = EventTileViewModel.createRenderState(this.createViewModelProps(renderInputs)); + this.viewModel.setProps(this.createViewModelProps(renderInputs)); + const eventTileRenderState = this.viewModel.getSnapshot(); const eventTileSnapshot = eventTileRenderState.snapshot; const lineClasses = eventTileRenderState.line.className; @@ -1002,9 +971,8 @@ export class UnwrappedEventTile extends React.Component const permalink = this.getPermalink(); - // we can't use local echoes as scroll tokens, because their event IDs change. - // Local echos have a send "status". const scrollToken = eventTileRenderState.root.scrollToken; + const rootRenderState = { tileClasses, tileAriaLive, scrollToken }; const avatar = ( @@ -1032,52 +1000,30 @@ export class UnwrappedEventTile extends React.Component /> ) : undefined; - // Thread panel shows the timestamp of the last reply in that thread const ts = eventTileRenderState.timestamp.value; const messageTimestampProps = this.createMessageTimestampProps(ts); const linkedMessageTimestampProps = this.createLinkedMessageTimestampProps(messageTimestampProps); - // Used to simplify the UI layout where necessary by not conditionally rendering an element at the start - const dummyTimestamp = eventTileRenderState.timestamp.showDummy ? ( - - ) : null; - const timestamp = eventTileRenderState.timestamp.displayState.showRealTimestamp ? ( - - ) : ( - dummyTimestamp ); - const linkedTimestamp = eventTileRenderState.timestamp.displayState.showLinkedTimestamp ? ( - - ) : ( - dummyTimestamp ); - let pinnedMessageBadge: JSX.Element | undefined; - if (hasPinnedMessageBadge) { - pinnedMessageBadge = ; - } - - let reactionsRow: JSX.Element | undefined; - if (hasReactionsRow) { - reactionsRow = ( - - ); - } - const groupTimestamp = eventTileRenderState.timestamp.showInGroupLine ? linkedTimestamp : null; const ircTimestamp = eventTileRenderState.timestamp.showInIrcLine ? linkedTimestamp : null; const groupPadlock = eventTileRenderState.e2ePadlock.showInGroupLine && this.renderE2EPadlock(); @@ -1128,19 +1074,11 @@ export class UnwrappedEventTile extends React.Component return React.createElement( this.props.as || "li", { - "ref": this.ref, - "className": tileClasses, - "aria-live": tileAriaLive, - "aria-atomic": true, - "data-scroll-tokens": scrollToken, + ...this.createInteractiveRootAttributes(rootRenderState), "data-has-reply": !!replyChain, "data-layout": this.props.layout, "data-self": isOwnEvent, "data-event-id": this.props.mxEvent.getId(), - "onMouseEnter": this.onMouseEnter, - "onMouseLeave": this.onMouseLeave, - "onFocus": this.onFocusWithin, - "onBlur": this.onBlurWithin, }, [
@@ -1155,55 +1093,46 @@ export class UnwrappedEventTile extends React.Component > {this.renderContextMenu()} {replyChain} - {renderTile(TimelineRenderingType.Thread, { - ...this.props, - - // overrides - ref: this.tile, - replacingEventId, - isSeeingThroughMessageHiddenForModeration, - - // appease TS - highlights: this.props.highlights, - highlightLink: this.props.highlightLink, - permalinkCreator: this.props.permalinkCreator!, - showHiddenEvents: this.context.showHiddenEvents, - })} + {renderTile( + TimelineRenderingType.Thread, + this.createRenderTileProps({ + replacingEventId, + isSeeingThroughMessageHiddenForModeration, + permalinkCreator: this.props.permalinkCreator!, + }), + )} {actionBar} {linkedTimestamp} {msgOption}
, - hasFooter && ( -
- {showMainPinnedMessageBadge && pinnedMessageBadge} - {reactionsRow} - {showBubblePinnedMessageBadge && pinnedMessageBadge} -
- ), + , ], ); } case TimelineRenderingType.Notification: case TimelineRenderingType.ThreadsList: { const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId()); - // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return React.createElement( this.props.as || "li", { - "ref": this.ref, - "className": tileClasses, + ...this.createInteractiveRootAttributes(rootRenderState), "tabIndex": -1, - "aria-live": tileAriaLive, - "aria-atomic": "true", - "data-scroll-tokens": scrollToken, "data-layout": this.props.layout, "data-shape": this.context.timelineRenderingType, "data-self": isOwnEvent, "data-has-reply": !!replyChain, - "onMouseEnter": this.onMouseEnter, - "onMouseLeave": this.onMouseLeave, - "onFocus": this.onFocusWithin, - "onBlur": this.onBlurWithin, "onClick": (ev: MouseEvent) => { const target = ev.currentTarget as HTMLElement; let index = -1; @@ -1253,19 +1182,11 @@ export class UnwrappedEventTile extends React.Component avatar )}
-
- {this.props.mxEvent.isRedacted() ? ( - - ) : this.props.mxEvent.isDecryptionFailure() ? ( - - ) : ( - - )} -
- {this.renderThreadPanelSummary(threadState)} + +
{this.context.timelineRenderingType === TimelineRenderingType.ThreadsList && ( ); } case TimelineRenderingType.File: { - return React.createElement( - this.props.as || "li", - { - "className": tileClasses, - "aria-live": tileAriaLive, - "aria-atomic": true, - "data-scroll-tokens": scrollToken, - }, - [ - -
- {avatar} - {sender} - {timestamp} -
-
, -
- {this.renderContextMenu()} - {renderTile(TimelineRenderingType.File, { - ...this.props, - - // overrides - ref: this.tile, + return React.createElement(this.props.as || "li", this.createRootAttributes(rootRenderState), [ + +
+ {avatar} + {sender} + {timestamp} +
+
, +
+ {this.renderContextMenu()} + {renderTile( + TimelineRenderingType.File, + this.createRenderTileProps({ isSeeingThroughMessageHiddenForModeration, - - // appease TS - highlights: this.props.highlights, - highlightLink: this.props.highlightLink, - permalinkCreator: this.props.permalinkCreator, - showHiddenEvents: this.context.showHiddenEvents, - })} -
, - ], - ); + }), + )} +
, + ]); } default: { // Pinned, Room, Search - // tab-index=-1 to allow it to be focusable but do not add tab stop for it, primarily for screen readers return React.createElement( this.props.as || "li", { - "ref": this.ref, - "className": tileClasses, + ...this.createInteractiveRootAttributes(rootRenderState), "tabIndex": -1, - "aria-live": tileAriaLive, - "aria-atomic": "true", - "data-scroll-tokens": scrollToken, "data-layout": this.props.layout, "data-self": isOwnEvent, "data-event-id": this.props.mxEvent.getId(), "data-has-reply": !!replyChain, - "onMouseEnter": this.onMouseEnter, - "onMouseLeave": this.onMouseLeave, - "onFocus": this.onFocusWithin, - "onBlur": this.onBlurWithin, }, <> {ircTimestamp} @@ -1358,42 +1254,52 @@ export class UnwrappedEventTile extends React.Component {groupTimestamp} {groupPadlock} {replyChain} - {renderTile(this.context.timelineRenderingType, { - ...this.props, - - // overrides - ref: this.tile, - isSeeingThroughMessageHiddenForModeration, - - // appease TS - highlights: this.props.highlights, - highlightLink: this.props.highlightLink, - permalinkCreator: this.props.permalinkCreator, - showHiddenEvents: this.context.showHiddenEvents, - })} + {renderTile( + this.context.timelineRenderingType, + this.createRenderTileProps({ + isSeeingThroughMessageHiddenForModeration, + }), + )} {actionBar} {eventTileRenderState.footer.showInIrcLayout && ( <> - {hasFooter && ( -
- {pinnedMessageBadge} - {reactionsRow} -
- )} - {this.renderThreadInfo(threadState)} + + )} {eventTileRenderState.footer.showInDefaultLayout && ( <> - {hasFooter && ( -
- {showMainPinnedMessageBadge && pinnedMessageBadge} - {reactionsRow} - {showBubblePinnedMessageBadge && pinnedMessageBadge} -
- )} - {this.renderThreadInfo(threadState)} + + )} {msgOption} @@ -1455,7 +1361,7 @@ class EventTileErrorBoundary extends React.Component { return ( diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileFooter.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileFooter.tsx new file mode 100644 index 0000000000..28b74086cf --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTileFooter.tsx @@ -0,0 +1,73 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; +import { type MatrixEvent, type Relations } from "matrix-js-sdk/src/matrix"; +import { PinnedMessageBadge } from "@element-hq/web-shared-components"; + +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { ReactionsRowAdapter } from "./ReactionsRowAdapter"; + +interface EventTileFooterProps { + eventTileViewModel: EventTileViewModel; + mxEvent: MatrixEvent; + reactions?: Relations | null; + hasFooter: boolean; + hasPinnedMessageBadge: boolean; + hasReactionsRow: boolean; + pinnedMessageBadgeAriaDescribedBy: string; + placement: "default" | "irc"; + showMainPinnedMessageBadge?: boolean; + showBubblePinnedMessageBadge?: boolean; +} + +/** Renders the pinned-message badge and reactions row for an EventTile. */ +export function EventTileFooter({ + eventTileViewModel, + mxEvent, + reactions, + hasFooter, + hasPinnedMessageBadge, + hasReactionsRow, + pinnedMessageBadgeAriaDescribedBy, + placement, + showMainPinnedMessageBadge, + showBubblePinnedMessageBadge, +}: Readonly): JSX.Element | null { + if (!hasFooter) { + return null; + } + + const pinnedMessageBadge = hasPinnedMessageBadge ? ( + + ) : undefined; + const reactionsRow = hasReactionsRow ? ( + + ) : undefined; + + if (placement === "irc") { + return ( +
+ {pinnedMessageBadge} + {reactionsRow} +
+ ); + } + + return ( +
+ {showMainPinnedMessageBadge && pinnedMessageBadge} + {reactionsRow} + {showBubblePinnedMessageBadge && pinnedMessageBadge} +
+ ); +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTilePreviewBody.tsx b/apps/web/src/components/views/rooms/EventTile/EventTilePreviewBody.tsx new file mode 100644 index 0000000000..7ef4ba1862 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTilePreviewBody.tsx @@ -0,0 +1,36 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../../messages/MBodyFactory"; +import { EventPreviewAdapter } from "./EventPreviewAdapter"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; + +interface EventTilePreviewBodyProps { + eventTileViewModel: EventTileViewModel; + mxEvent: MatrixEvent; +} + +/** Renders the compact body preview used by notification and thread-list tiles. */ +export function EventTilePreviewBody({ + eventTileViewModel, + mxEvent, +}: Readonly): JSX.Element { + let body: JSX.Element; + + if (mxEvent.isRedacted()) { + body = ; + } else if (mxEvent.isDecryptionFailure()) { + body = ; + } else { + body = ; + } + + return
{body}
; +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileThreadInfo.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileThreadInfo.tsx new file mode 100644 index 0000000000..b873c7f551 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTileThreadInfo.tsx @@ -0,0 +1,78 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX, type ReactNode } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { ThreadsIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { _t } from "../../../../languageHandler"; +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { type EventTileThreadState } from "../../../../viewmodels/room/timeline/event-tile/EventTileThreadState"; +import { ThreadMessagePreviewAdapter } from "./ThreadMessagePreviewAdapter"; +import { ThreadSummaryAdapter } from "./ThreadSummaryAdapter"; + +interface EventTileThreadInfoProps { + eventTileViewModel: EventTileViewModel; + mxEvent: MatrixEvent; + threadState: EventTileThreadState; +} + +/** Renders the thread-panel reply summary shown under a preview tile. */ +export function EventTileThreadPanelSummary({ + eventTileViewModel, + threadState, +}: Readonly>): JSX.Element | null { + if (!threadState.shouldShowThreadPanelSummary || !threadState.thread) { + return null; + } + + return ( +
+ + {threadState.thread.length} + +
+ ); +} + +/** Renders thread summary or search thread affordances for an EventTile. */ +export function EventTileThreadInfo({ + eventTileViewModel, + mxEvent, + threadState, +}: Readonly): ReactNode { + if (threadState.shouldShowThreadSummary && threadState.thread) { + return ( + + ); + } + + if (threadState.searchThreadInfo.kind === "link") { + return ( + + + {_t("timeline|thread_info_basic")} + + ); + } + + if (threadState.searchThreadInfo.kind === "text") { + return ( +

+ + {_t("timeline|thread_info_basic")} +

+ ); + } + + return null; +} diff --git a/apps/web/src/components/views/rooms/EventTile/EventTileTimestampSlot.tsx b/apps/web/src/components/views/rooms/EventTile/EventTileTimestampSlot.tsx new file mode 100644 index 0000000000..f48eafea19 --- /dev/null +++ b/apps/web/src/components/views/rooms/EventTile/EventTileTimestampSlot.tsx @@ -0,0 +1,45 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type JSX } from "react"; + +import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel"; +import { type MessageTimestampViewModelProps } from "../../../../viewmodels/room/timeline/event-tile/timestamp/MessageTimestampViewModel"; +import { MessageTimestampAdapter } from "./MessageTimestampAdapter"; + +interface EventTileTimestampSlotProps { + eventTileViewModel: EventTileViewModel; + kind: "plain" | "linked"; + timestampProps: MessageTimestampViewModelProps; + showTimestamp: boolean; + showDummy: boolean; +} + +/** Renders a real or placeholder timestamp slot for an EventTile. */ +export function EventTileTimestampSlot({ + eventTileViewModel, + kind, + timestampProps, + showTimestamp, + showDummy, +}: Readonly): JSX.Element | null { + if (showTimestamp) { + return ( + + ); + } + + if (showDummy) { + return ; + } + + return null; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileHighlightState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileHighlightState.ts new file mode 100644 index 0000000000..8a4b9657d8 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileHighlightState.ts @@ -0,0 +1,58 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { EventType, type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { TimelineRenderingType } from "../../../../contexts/RoomContext"; + +/** Inputs for deriving EventTile highlight display state. */ +export interface EventTileHighlightStateInput { + /** Matrix client used to resolve push actions and the current user. */ + cli: MatrixClient; + /** Matrix event rendered by the tile. */ + mxEvent: MatrixEvent; + /** Current timeline rendering mode. */ + timelineRenderingType: TimelineRenderingType; + /** Whether the tile is rendering for export. */ + forExport?: boolean; + /** Whether the event is considered redacted by the tile. */ + isRedacted?: boolean; +} + +/** + * Determine whether an event should be highlighted. + * For edited events, if a previous version of the event was highlighted, the event should remain highlighted as the + * user may have been notified. + */ +export function shouldHighlightEventTile({ + cli, + mxEvent, + timelineRenderingType, + forExport, + isRedacted, +}: EventTileHighlightStateInput): boolean { + if (forExport) return false; + if (timelineRenderingType === TimelineRenderingType.Notification) return false; + if (timelineRenderingType === TimelineRenderingType.ThreadsList) return false; + if (isRedacted) return false; + + // This event is a room mention but we don't want the call tile to have a highlight. + if (mxEvent.getType() === EventType.RTCNotification) return false; + + const actions = cli.getPushActionsForEvent(mxEvent.replacingEvent() || mxEvent); + const previousActions = mxEvent.replacingEvent() ? cli.getPushActionsForEvent(mxEvent) : undefined; + if (!actions?.tweaks && !previousActions?.tweaks) { + return false; + } + + // Don't show self-highlights from another of our clients. + if (mxEvent.getSender() === cli.credentials.userId) { + return false; + } + + return !!(actions?.tweaks.highlight || previousActions?.tweaks.highlight); +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventTileVisibilityState.ts b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileVisibilityState.ts new file mode 100644 index 0000000000..4f8dd3fad7 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventTileVisibilityState.ts @@ -0,0 +1,28 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call"; + +/** Call event state used to decide whether EventTile should suppress rendering. */ +export interface EventTileCallEventState { + /** Legacy call hangup reason, when available. */ + hangupReason?: string | null; +} + +/** Inputs for deriving EventTile hidden display state. */ +export interface EventTileVisibilityStateInput { + /** Legacy call event grouping state for this tile. */ + callEventGrouper?: EventTileCallEventState; +} + +/** + * In some cases EventTile cannot rely on the event's own visibility because replacement call events are rendered by + * the replacing call tile instead. + */ +export function shouldHideEventTile({ callEventGrouper }: EventTileVisibilityStateInput): boolean { + return callEventGrouper?.hangupReason === CallErrorCode.Replaced; +} diff --git a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap index bf5258b68a..36ed656a21 100644 --- a/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap +++ b/apps/web/test/unit-tests/utils/exportUtils/__snapshots__/HTMLExport-test.ts.snap @@ -57,7 +57,7 @@ exports[`HTMLExport should export 1`] = `

-
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0
  • +
  • @user49:example.com
    00:00
    Message #49
  • @user48:example.com
    00:00
    Message #48
  • @user47:example.com
    00:00
    Message #47
  • @user46:example.com
    00:00
    Message #46
  • @user45:example.com
    00:00
    Message #45
  • @user44:example.com
    00:00
    Message #44
  • @user43:example.com
    00:00
    Message #43
  • @user42:example.com
    00:00
    Message #42
  • @user41:example.com
    00:00
    Message #41
  • @user40:example.com
    00:00
    Message #40
  • @user39:example.com
    00:00
    Message #39
  • @user38:example.com
    00:00
    Message #38
  • @user37:example.com
    00:00
    Message #37
  • @user36:example.com
    00:00
    Message #36
  • @user35:example.com
    00:00
    Message #35
  • @user34:example.com
    00:00
    Message #34
  • @user33:example.com
    00:00
    Message #33
  • @user32:example.com
    00:00
    Message #32
  • @user31:example.com
    00:00
    Message #31
  • @user30:example.com
    00:00
    Message #30
  • @user29:example.com
    00:00
    Message #29
  • @user28:example.com
    00:00
    Message #28
  • @user27:example.com
    00:00
    Message #27
  • @user26:example.com
    00:00
    Message #26
  • @user25:example.com
    00:00
    Message #25
  • @user24:example.com
    00:00
    Message #24
  • @user23:example.com
    00:00
    Message #23
  • @user22:example.com
    00:00
    Message #22
  • @user21:example.com
    00:00
    Message #21
  • @user20:example.com
    00:00
    Message #20
  • @user19:example.com
    00:00
    Message #19
  • @user18:example.com
    00:00
    Message #18
  • @user17:example.com
    00:00
    Message #17
  • @user16:example.com
    00:00
    Message #16
  • @user15:example.com
    00:00
    Message #15
  • @user14:example.com
    00:00
    Message #14
  • @user13:example.com
    00:00
    Message #13
  • @user12:example.com
    00:00
    Message #12
  • @user11:example.com
    00:00
    Message #11
  • @user10:example.com
    00:00
    Message #10
  • @user9:example.com
    00:00
    Message #9
  • @user8:example.com
    00:00
    Message #8
  • @user7:example.com
    00:00
    Message #7
  • @user6:example.com
    00:00
    Message #6
  • @user5:example.com
    00:00
    Message #5
  • @user4:example.com
    00:00
    Message #4
  • @user3:example.com
    00:00
    Message #3
  • @user2:example.com
    00:00
    Message #2
  • @user1:example.com
    00:00
    Message #1
  • @user0:example.com
    00:00
    Message #0