From 3ba801c5abb0da08f295306e7e4a433d9d9914c5 Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 28 May 2026 13:54:42 +0200 Subject: [PATCH 1/4] Refactor EventPreview to shared MVVM --- apps/web/res/css/_components.pcss | 1 - .../components/views/rooms/EventPreview.tsx | 138 ------------ .../src/components/views/rooms/EventTile.tsx | 24 +- .../views/rooms/PinnedMessageBanner.tsx | 25 ++- .../event-tile/EventPreviewViewModel.tsx | 206 ++++++++++++++++++ .../PinnedMessageBanner-test.tsx.snap | 52 ++--- .../event-tile/EventPreviewViewModel-test.tsx | 165 ++++++++++++++ packages/shared-components/src/index.ts | 1 + .../EventPreviewView.module.css | 11 +- .../EventPreviewView.stories.tsx | 58 +++++ .../EventPreviewView.test.tsx | 50 +++++ .../EventPreviewView/EventPreviewView.tsx | 60 +++++ .../EventPreviewView.test.tsx.snap | 24 ++ .../EventTileView/EventPreviewView/index.tsx | 12 + 14 files changed, 645 insertions(+), 182 deletions(-) delete mode 100644 apps/web/src/components/views/rooms/EventPreview.tsx create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx create mode 100644 apps/web/test/unit-tests/viewmodels/room/timeline/event-tile/EventPreviewViewModel-test.tsx rename apps/web/res/css/views/rooms/_EventPreview.pcss => packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.module.css (60%) create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.test.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/__snapshots__/EventPreviewView.test.tsx.snap create mode 100644 packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 4e52de8a7f8..0c2757148f6 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -255,7 +255,6 @@ @import "./views/rooms/_EditMessageComposer.pcss"; @import "./views/rooms/_EmojiButton.pcss"; @import "./views/rooms/_EventBubbleTile.pcss"; -@import "./views/rooms/_EventPreview.pcss"; @import "./views/rooms/_EventTile.pcss"; @import "./views/rooms/_HistoryTile.pcss"; @import "./views/rooms/_IRCLayout.pcss"; diff --git a/apps/web/src/components/views/rooms/EventPreview.tsx b/apps/web/src/components/views/rooms/EventPreview.tsx deleted file mode 100644 index 144f1d5aa0c..00000000000 --- a/apps/web/src/components/views/rooms/EventPreview.tsx +++ /dev/null @@ -1,138 +0,0 @@ -/* - * Copyright 2024 New Vector Ltd. - * Copyright 2024 The Matrix.org Foundation C.I.C. - * - * 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 HTMLProps, type JSX, useContext, useState } from "react"; -import { type IContent, M_POLL_START, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix"; -import classNames from "classnames"; - -import { _t } from "../../../languageHandler"; -import { MessagePreviewStore } from "../../../stores/message-preview"; -import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; -import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts"; - -/** - * The props for the {@link EventPreview} component. - */ -interface Props extends HTMLProps { - /** - * The event to display the preview for - */ - mxEvent: MatrixEvent; -} - -/** - * A component that displays a preview for the given event. - * Wraps both `useEventPreview` & `EventPreviewTile`. - */ -export function EventPreview({ mxEvent, className, ...props }: Props): JSX.Element | null { - const preview = useEventPreview(mxEvent); - if (!preview) return null; - - return ; -} - -/** - * The props for the {@link EventPreviewTile} component. - */ -interface EventPreviewTileProps extends HTMLProps { - /** - * The preview to display - */ - preview: Preview; -} - -/** - * A component that displays a preview given the output from `useEventPreview`. - */ -export function EventPreviewTile({ - preview: [preview, prefix], - className, - ...props -}: EventPreviewTileProps): JSX.Element | null { - const classes = classNames("mx_EventPreview", className); - if (!prefix) - return ( - - {preview} - - ); - - return ( - - {_t( - "event_preview|preview", - { - prefix, - preview, - }, - { - bold: (sub) => {sub}, - }, - )} - - ); -} - -type Preview = [preview: string, prefix: string | null]; - -/** - * Hooks to generate a preview for the event. - * @param mxEvent - */ -export function useEventPreview(mxEvent: MatrixEvent | undefined): Preview | null { - const cli = useContext(MatrixClientContext); - // track the content as a means to regenerate the preview upon edits & decryption - const [content, setContent] = useState(mxEvent?.getContent()); - useTypedEventEmitter(mxEvent ?? undefined, MatrixEventEvent.Replaced, () => { - setContent(mxEvent!.getContent()); - }); - const awaitDecryption = mxEvent?.shouldAttemptDecryption() || mxEvent?.isBeingDecrypted(); - useTypedEventEmitter(awaitDecryption ? (mxEvent ?? undefined) : undefined, MatrixEventEvent.Decrypted, () => { - setContent(mxEvent!.getContent()); - }); - - return useAsyncMemo( - async () => { - if (!mxEvent || mxEvent.isRedacted() || mxEvent.isDecryptionFailure()) return null; - await cli.decryptEventIfNeeded(mxEvent); - return [ - MessagePreviewStore.instance.generatePreviewForEvent(mxEvent), - getPreviewPrefix(mxEvent.getType(), content?.msgtype as MsgType), - ]; - }, - [mxEvent, content], - null, - ); -} - -/** - * Get the prefix for the preview based on the type and the message type. - * @param type - * @param msgType - */ -function getPreviewPrefix(type: string, msgType: MsgType): string | null { - switch (type) { - case M_POLL_START.name: - return _t("event_preview|prefix|poll"); - default: - } - - switch (msgType) { - case MsgType.Audio: - return _t("event_preview|prefix|audio"); - case MsgType.Image: - return _t("event_preview|prefix|image"); - case MsgType.Video: - return _t("event_preview|prefix|video"); - case MsgType.File: - return _t("event_preview|prefix|file"); - default: - return null; - } -} diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index dd6e32ee0e2..98c00c525d7 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -43,6 +43,7 @@ import { useCreateAutoDisposedViewModel, ActionBarView, E2eMessageSharedIconView, + EventPreviewView, MessageTimestampView, PinnedMessageBadge, ReactionsRowButtonView, @@ -89,7 +90,6 @@ import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationB import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper"; import { Icon as LateIcon } from "../../../../res/img/sensor.svg"; import PinningUtils from "../../../utils/PinningUtils"; -import { EventPreview } from "./EventPreview"; import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon"; import SettingsStore from "../../../settings/SettingsStore"; import { CardContext } from "../right_panel/context"; @@ -143,6 +143,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useSettingValue } from "../../../hooks/useSettings"; import { DecryptionFailureBodyFactory, RedactedBodyFactory } from "../messages/MBodyFactory"; import { EventTileE2eViewModel } from "../../../viewmodels/room/timeline/event-tile/EventTileE2eViewModel"; +import { EventPreviewViewModel } from "../../../viewmodels/room/timeline/event-tile/EventPreviewViewModel"; /** Relation lookup type retained for EventTile consumers. */ export type { GetRelationsForEvent } from "../../../viewmodels/room/timeline/event-tile/reactions/EventTileReactionState"; @@ -1297,7 +1298,7 @@ export class UnwrappedEventTile extends React.Component ) : this.props.mxEvent.isDecryptionFailure() ? ( ) : ( - + )} {this.renderThreadPanelSummary(threadState)} @@ -1563,6 +1564,25 @@ function MessageTimestampWrapper(props: MessageTimestampViewModelProps): JSX.Ele ); } +type EventPreviewWrapperProps = Omit, "children" | "title"> & { + mxEvent: MatrixEvent; +}; + +function EventPreviewWrapper({ mxEvent, ...props }: Readonly): JSX.Element { + const cli = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel(() => new EventPreviewViewModel({ cli, mxEvent })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setClient(cli); + }, [cli, vm]); + + return ; +} + interface ThreadMessagePreviewWrapperProps { thread: Thread; showDisplayName?: boolean; diff --git a/apps/web/src/components/views/rooms/PinnedMessageBanner.tsx b/apps/web/src/components/views/rooms/PinnedMessageBanner.tsx index 552aa2a4b2e..0c1f72cf8e5 100644 --- a/apps/web/src/components/views/rooms/PinnedMessageBanner.tsx +++ b/apps/web/src/components/views/rooms/PinnedMessageBanner.tsx @@ -11,6 +11,7 @@ import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin-soli import { Button } from "@vector-im/compound-web"; import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; +import { EventPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents"; import { _t } from "../../../languageHandler"; @@ -24,8 +25,9 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo import { Action } from "../../../dispatcher/actions"; import MessageEvent from "../messages/MessageEvent"; import PosthogTrackers from "../../../PosthogTrackers.ts"; -import { EventPreview } from "./EventPreview.tsx"; import { SDKContext } from "../../../contexts/SDKContext.ts"; +import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import { EventPreviewViewModel } from "../../../viewmodels/room/timeline/event-tile/EventPreviewViewModel"; /** * The props for the {@link PinnedMessageBanner} component. @@ -118,7 +120,7 @@ export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBan )} )} - , "children" | "title"> & { + mxEvent: MatrixEvent; +}; + +function EventPreviewWrapper({ mxEvent, ...props }: Readonly): JSX.Element { + const cli = useContext(MatrixClientContext); + const vm = useCreateAutoDisposedViewModel(() => new EventPreviewViewModel({ cli, mxEvent })); + + useEffect(() => { + vm.setEvent(mxEvent); + }, [mxEvent, vm]); + + useEffect(() => { + vm.setClient(cli); + }, [cli, vm]); + + return ; +} + /** * When the banner is displayed or hidden, we want to notify the timeline to resize itself. * @param pinnedEvent diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx new file mode 100644 index 00000000000..7f28b017468 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx @@ -0,0 +1,206 @@ +/* + * 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 ReactNode } from "react"; +import { M_POLL_START, type MatrixClient, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix"; +import { logger } from "matrix-js-sdk/src/logger"; +import { + BaseViewModel, + type EventPreviewViewModel as EventPreviewViewModelInterface, + type EventPreviewViewSnapshot, +} from "@element-hq/web-shared-components"; + +import { _t } from "../../../../languageHandler"; +import { MessagePreviewStore } from "../../../../stores/message-preview"; + +export interface EventPreviewViewModelProps { + /** + * Matrix client used to decrypt event content before preview generation. + */ + cli: MatrixClient; + /** + * The event to display the preview for. + */ + mxEvent?: MatrixEvent; +} + +export class EventPreviewViewModel + extends BaseViewModel + implements EventPreviewViewModelInterface +{ + private eventListenerCleanups: Array<() => void> = []; + private watchedEvent?: MatrixEvent; + private previewRequestId = 0; + private previewContentKey?: string; + private previewContent?: ReactNode; + + private static readonly hiddenSnapshot: EventPreviewViewSnapshot = { + isVisible: false, + }; + + public constructor(props: EventPreviewViewModelProps) { + super(props, EventPreviewViewModel.hiddenSnapshot); + + this.disposables.track(() => this.teardownEventListeners()); + this.setupEventListeners(props.mxEvent); + void this.updatePreview(); + } + + public setEvent(mxEvent?: MatrixEvent): void { + if (this.props.mxEvent === mxEvent) return; + + this.props = { + ...this.props, + mxEvent, + }; + this.setupEventListeners(mxEvent); + void this.updatePreview(); + } + + public setClient(cli: MatrixClient): void { + if (this.props.cli === cli) return; + + this.props = { + ...this.props, + cli, + }; + void this.updatePreview(); + } + + private setupEventListeners(mxEvent?: MatrixEvent): void { + if (this.watchedEvent === mxEvent) return; + + this.teardownEventListeners(); + this.watchedEvent = mxEvent; + + if (!mxEvent) return; + + mxEvent.on(MatrixEventEvent.Replaced, this.onEventContentChanged); + mxEvent.on(MatrixEventEvent.Decrypted, this.onEventContentChanged); + this.eventListenerCleanups.push(() => { + mxEvent.off(MatrixEventEvent.Replaced, this.onEventContentChanged); + mxEvent.off(MatrixEventEvent.Decrypted, this.onEventContentChanged); + }); + } + + private teardownEventListeners(): void { + for (const cleanup of this.eventListenerCleanups) { + cleanup(); + } + this.eventListenerCleanups = []; + this.watchedEvent = undefined; + } + + private onEventContentChanged = (): void => { + void this.updatePreview(); + }; + + private async updatePreview(): Promise { + const { cli, mxEvent } = this.props; + const requestId = ++this.previewRequestId; + + if (!mxEvent || mxEvent.isRedacted() || mxEvent.isDecryptionFailure()) { + this.setHidden(); + return; + } + + try { + await cli.decryptEventIfNeeded(mxEvent); + } catch (error) { + logger.error("Failed to decrypt event preview", error); + if (this.isCurrentPreviewRequest(requestId, cli, mxEvent)) { + this.setHidden(); + } + return; + } + + if (!this.isCurrentPreviewRequest(requestId, cli, mxEvent)) return; + + if (mxEvent.isRedacted() || mxEvent.isDecryptionFailure()) { + this.setHidden(); + return; + } + + const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent); + if (!preview) { + this.setHidden(); + return; + } + + const prefix = EventPreviewViewModel.getPreviewPrefix( + mxEvent.getType(), + mxEvent.getContent().msgtype as MsgType | undefined, + ); + + this.snapshot.merge({ + isVisible: true, + previewContent: this.getPreviewContent(preview, prefix), + previewTooltip: prefix ? undefined : preview, + }); + } + + private isCurrentPreviewRequest(requestId: number, cli: MatrixClient, mxEvent: MatrixEvent): boolean { + return ( + !this.isDisposed && + requestId === this.previewRequestId && + this.props.cli === cli && + this.props.mxEvent === mxEvent + ); + } + + private setHidden(): void { + this.snapshot.merge({ + isVisible: false, + previewContent: undefined, + previewTooltip: undefined, + }); + } + + private getPreviewContent(preview: string, prefix: string | null): ReactNode { + const key = `${prefix ?? ""}\u0000${preview}`; + if (this.previewContentKey === key && this.previewContent !== undefined) { + return this.previewContent; + } + + this.previewContentKey = key; + this.previewContent = prefix + ? _t( + "event_preview|preview", + { + prefix, + preview, + }, + { + bold: (sub) => {sub}, + }, + ) + : preview; + + return this.previewContent; + } + + private static getPreviewPrefix(type: string, msgType?: MsgType): string | null { + switch (type) { + case M_POLL_START.name: + return _t("event_preview|prefix|poll"); + default: + } + + switch (msgType) { + case MsgType.Audio: + return _t("event_preview|prefix|audio"); + case MsgType.Image: + return _t("event_preview|prefix|image"); + case MsgType.Video: + return _t("event_preview|prefix|video"); + case MsgType.File: + return _t("event_preview|prefix|file"); + default: + return null; + } + } +} diff --git a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap index 200f73896d0..f227824dfd7 100644 --- a/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap +++ b/apps/web/test/unit-tests/components/views/rooms/__snapshots__/PinnedMessageBanner-test.tsx.snap @@ -40,15 +40,13 @@ exports[` should display display a poll event 1`] = ` /> - + Poll: - + Alice? @@ -119,9 +117,8 @@ exports[` should display the last message when the pinned Third pinned message @@ -180,15 +177,13 @@ exports[` should display the m.audio event type 1`] = ` /> - + Audio: - + Message with m.audio type @@ -238,15 +233,13 @@ exports[` should display the m.file event type 1`] = ` /> - + File: - + Message with m.file type @@ -296,15 +289,13 @@ exports[` should display the m.image event type 1`] = ` /> - + Image: - + Message with m.image type @@ -354,15 +345,13 @@ exports[` should display the m.video event type 1`] = ` /> - + Video: - + Message with m.video type @@ -429,9 +418,8 @@ exports[` should render 2 pinned event 1`] = ` Second pinned message @@ -511,9 +499,8 @@ exports[` should render 4 pinned event 1`] = ` Fourth pinned message @@ -572,9 +559,8 @@ exports[` should render a single pinned event 1`] = ` /> First pinned message diff --git a/apps/web/test/unit-tests/viewmodels/room/timeline/event-tile/EventPreviewViewModel-test.tsx b/apps/web/test/unit-tests/viewmodels/room/timeline/event-tile/EventPreviewViewModel-test.tsx new file mode 100644 index 00000000000..20843339bc1 --- /dev/null +++ b/apps/web/test/unit-tests/viewmodels/room/timeline/event-tile/EventPreviewViewModel-test.tsx @@ -0,0 +1,165 @@ +/* + * 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 from "react"; +import { render, screen } from "jest-matrix-react"; +import { EventType, type MatrixClient, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix"; + +import { EventPreviewViewModel } from "../../../../../../src/viewmodels/room/timeline/event-tile/EventPreviewViewModel"; +import { flushPromises, mkEvent, stubClient } from "../../../../../test-utils"; + +describe("EventPreviewViewModel", () => { + const roomId = "!room:example.com"; + const userId = "@alice:example.com"; + + let cli: MatrixClient; + let decryptEventIfNeeded: jest.SpiedFunction; + + const makeMessageEvent = ({ + body = "Hello", + msgtype = MsgType.Text, + }: { + body?: string; + msgtype?: MsgType; + } = {}): MatrixEvent => + mkEvent({ + event: true, + type: EventType.RoomMessage, + room: roomId, + user: userId, + content: { + body, + msgtype, + }, + }); + + beforeEach(() => { + cli = stubClient(); + decryptEventIfNeeded = jest.spyOn(cli, "decryptEventIfNeeded").mockResolvedValue(undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("generates a text preview and tooltip", async () => { + const mxEvent = makeMessageEvent({ body: "Text preview" }); + const vm = new EventPreviewViewModel({ cli, mxEvent }); + + await flushPromises(); + + expect(decryptEventIfNeeded).toHaveBeenCalledWith(mxEvent); + expect(vm.getSnapshot()).toMatchObject({ + isVisible: true, + previewContent: "Text preview", + previewTooltip: "Text preview", + }); + }); + + it("generates prefixed media preview content", async () => { + const mxEvent = makeMessageEvent({ body: "clip.mp4", msgtype: MsgType.Video }); + const vm = new EventPreviewViewModel({ cli, mxEvent }); + + await flushPromises(); + + render(<>{vm.getSnapshot().previewContent}); + + expect(screen.getByText("Video:")).toBeInTheDocument(); + expect(screen.getByText("Video:").tagName).toBe("STRONG"); + expect(screen.getByText("clip.mp4")).toBeInTheDocument(); + expect(vm.getSnapshot().previewTooltip).toBeUndefined(); + }); + + it("updates the preview when the event is replaced", async () => { + const mxEvent = makeMessageEvent({ body: "Original" }); + const vm = new EventPreviewViewModel({ cli, mxEvent }); + + await flushPromises(); + expect(vm.getSnapshot().previewContent).toBe("Original"); + + const replacementEvent = new MatrixEvent({ + type: EventType.RoomMessage, + room_id: roomId, + sender: userId, + content: { + "body": "Edited", + "msgtype": MsgType.Text, + "m.new_content": { + body: "Edited", + msgtype: MsgType.Text, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: mxEvent.getId(), + }, + }, + }); + + mxEvent.makeReplaced(replacementEvent); + await flushPromises(); + + expect(vm.getSnapshot().previewContent).toBe("Edited"); + }); + + it("skips unchanged setter inputs and updates for a different event", async () => { + const originalEvent = makeMessageEvent({ body: "Original" }); + const updatedEvent = makeMessageEvent({ body: "Updated" }); + const vm = new EventPreviewViewModel({ cli, mxEvent: originalEvent }); + const listener = jest.fn(); + + await flushPromises(); + decryptEventIfNeeded.mockClear(); + vm.subscribe(listener); + + vm.setEvent(originalEvent); + vm.setClient(cli); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + expect(decryptEventIfNeeded).not.toHaveBeenCalled(); + + vm.setEvent(updatedEvent); + await flushPromises(); + + expect(listener).toHaveBeenCalledTimes(1); + expect(vm.getSnapshot().previewContent).toBe("Updated"); + }); + + it("removes event listeners when disposed", async () => { + const mxEvent = makeMessageEvent({ body: "Original" }); + const vm = new EventPreviewViewModel({ cli, mxEvent }); + const listener = jest.fn(); + + await flushPromises(); + vm.subscribe(listener); + vm.dispose(); + + const replacementEvent = new MatrixEvent({ + type: EventType.RoomMessage, + room_id: roomId, + sender: userId, + content: { + "body": "Edited", + "msgtype": MsgType.Text, + "m.new_content": { + body: "Edited", + msgtype: MsgType.Text, + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: mxEvent.getId(), + }, + }, + }); + + mxEvent.makeReplaced(replacementEvent); + await flushPromises(); + + expect(listener).not.toHaveBeenCalled(); + expect(vm.getSnapshot().previewContent).toBe("Original"); + }); +}); diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index ef09ab72252..a8e7c805b53 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -43,6 +43,7 @@ export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile"; export * from "./room/timeline/event-tile/EventTileView/E2eMessageSharedIcon"; export * from "./room/timeline/event-tile/EventTileView/E2ePadlock"; export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView"; +export * from "./room/timeline/event-tile/EventTileView/EventPreviewView"; export * from "./room/timeline/event-tile/call"; export * from "./room/timeline/event-tile/EventTileView/EventTileBubble"; export * from "./room/timeline/event-tile/EventTileView/MJitsiWidgetEventView"; diff --git a/apps/web/res/css/views/rooms/_EventPreview.pcss b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.module.css similarity index 60% rename from apps/web/res/css/views/rooms/_EventPreview.pcss rename to packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.module.css index 6999810abfc..2a1f346dbfe 100644 --- a/apps/web/res/css/views/rooms/_EventPreview.pcss +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.module.css @@ -1,18 +1,17 @@ /* - * Copyright 2024 New Vector Ltd. - * Copyright 2024 The Matrix.org Foundation C.I.C. + * 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. */ -.mx_EventPreview { +.eventPreview { font: var(--cpd-font-body-sm-regular); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} - .mx_EventPreview_prefix { - font: var(--cpd-font-body-sm-semibold); - } +.eventPreview strong { + font: var(--cpd-font-body-sm-semibold); } diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx new file mode 100644 index 00000000000..583ad096757 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx @@ -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 React, { type JSX } from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { EventPreviewView, type EventPreviewViewSnapshot } from "./EventPreviewView"; + +type WrapperProps = EventPreviewViewSnapshot & { + className?: string; +}; + +const EventPreviewViewWrapperImpl = ({ className, ...snapshotProps }: WrapperProps): JSX.Element => { + const vm = useMockedViewModel(snapshotProps, {}); + + return ; +}; + +const EventPreviewViewWrapper = withViewDocs(EventPreviewViewWrapperImpl, EventPreviewView); + +const meta = { + title: "Timeline/EventTile/EventPreviewView", + component: EventPreviewViewWrapper, + tags: ["autodocs"], + args: { + isVisible: true, + previewContent: "A short text message preview", + previewTooltip: "A short text message preview", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithPrefix: Story = { + args: { + previewContent: ( + <> + Image: city-map.png + + ), + previewTooltip: undefined, + }, +}; + +export const Hidden: Story = { + args: { + isVisible: false, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.test.tsx new file mode 100644 index 00000000000..87c472e8d53 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.test.tsx @@ -0,0 +1,50 @@ +/* + * 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 from "react"; +import { composeStories } from "@storybook/react-vite"; +import { render, screen } from "@test-utils"; +import { describe, expect, it } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { EventPreviewView, type EventPreviewViewModel, type EventPreviewViewSnapshot } from "./EventPreviewView"; +import * as stories from "./EventPreviewView.stories"; + +const { Default, WithPrefix, Hidden } = composeStories(stories); + +describe("EventPreviewView", () => { + it("renders the default event preview", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it("renders a prefixed event preview", () => { + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); + + it("does not render when hidden", () => { + render(); + + expect(screen.queryByText("A short text message preview")).not.toBeInTheDocument(); + }); + + it("applies custom span props without using native title tooltips", () => { + const vm = new MockViewModel({ + isVisible: true, + previewContent: "Preview text", + previewTooltip: "Preview text", + }) as EventPreviewViewModel; + + render(); + + expect(screen.getByTestId("event-preview")).toHaveClass("mx_EventPreview", "custom-preview"); + expect(screen.getByTestId("event-preview")).not.toHaveAttribute("title"); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.tsx new file mode 100644 index 00000000000..3da9ce161df --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.tsx @@ -0,0 +1,60 @@ +/* + * 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 ComponentPropsWithoutRef, type JSX, type ReactNode } from "react"; +import classNames from "classnames"; +import { Tooltip } from "@vector-im/compound-web"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import styles from "./EventPreviewView.module.css"; + +export interface EventPreviewViewSnapshot { + /** + * Controls whether the preview should render. + */ + isVisible: boolean; + /** + * Rendered preview content. + */ + previewContent?: ReactNode; + /** + * Optional styled tooltip text for the preview content. + */ + previewTooltip?: string; +} + +export type EventPreviewViewModel = ViewModel; + +type EventPreviewViewProps = Omit, "children" | "title"> & { + /** + * The view model for the event preview. + */ + vm: EventPreviewViewModel; +}; + +/** + * Renders a compact preview of an event. + */ +export function EventPreviewView({ vm, className, ...props }: Readonly): JSX.Element { + const { isVisible, previewContent, previewTooltip } = useViewModel(vm); + + if (!isVisible || !previewContent) { + return <>; + } + + const preview = ( + + {previewContent} + + ); + + if (!previewTooltip) { + return preview; + } + + return {preview}; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/__snapshots__/EventPreviewView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/__snapshots__/EventPreviewView.test.tsx.snap new file mode 100644 index 00000000000..13681011a01 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/__snapshots__/EventPreviewView.test.tsx.snap @@ -0,0 +1,24 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EventPreviewView > renders a prefixed event preview 1`] = ` +
+ + + Image: + + city-map.png + +
+`; + +exports[`EventPreviewView > renders the default event preview 1`] = ` +
+ + A short text message preview + +
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx new file mode 100644 index 00000000000..423ffbf4c66 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx @@ -0,0 +1,12 @@ +/* + * 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. + */ + +export { + EventPreviewView, + type EventPreviewViewModel, + type EventPreviewViewSnapshot, +} from "./EventPreviewView"; From 1f8948ff56b11a855f30f66b800dc3771a8c6fc3 Mon Sep 17 00:00:00 2001 From: Zack Date: Fri, 29 May 2026 09:19:31 +0200 Subject: [PATCH 2/4] Fix EventPreviewView export formatting --- .../event-tile/EventTileView/EventPreviewView/index.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx index 423ffbf4c66..58f422d4e19 100644 --- a/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx @@ -5,8 +5,4 @@ * Please see LICENSE files in the repository root for full details. */ -export { - EventPreviewView, - type EventPreviewViewModel, - type EventPreviewViewSnapshot, -} from "./EventPreviewView"; +export { EventPreviewView, type EventPreviewViewModel, type EventPreviewViewSnapshot } from "./EventPreviewView"; From af185aa7a523f86f886829055d9658c8f37a24d6 Mon Sep 17 00:00:00 2001 From: Zack Date: Fri, 29 May 2026 09:24:54 +0200 Subject: [PATCH 3/4] Snapshots images for stories --- .../default-auto.png | Bin 0 -> 19674 bytes .../hidden-auto.png | Bin 0 -> 16380 bytes .../with-prefix-auto.png | Bin 0 -> 19055 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/hidden-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/with-prefix-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..b4718dac91544afebf6fc01557afe68c65947511 GIT binary patch literal 19674 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 WyV8?_MjV0_#>(YZ%i@TpC8f(TDg@>WWQf5NQ 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 Ro_InG>>t)Ir>}X@{{cu8Y-#`i literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/with-prefix-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/with-prefix-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..e01c2844b9349384f8d91ea736815f99a2b1c35b GIT binary patch literal 19055 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 literal 0 HcmV?d00001 From b388b25e11fb132265cac1297e7b7a762cae5f14 Mon Sep 17 00:00:00 2001 From: Zack Date: Fri, 29 May 2026 10:06:53 +0200 Subject: [PATCH 4/4] Deduplicate event preview formatting --- .../timeline/event-tile/EventPreviewUtils.tsx | 114 ++++++++++++++++++ .../event-tile/EventPreviewViewModel.tsx | 102 +++------------- .../event-tile/ThreadSummaryViewModel.tsx | 100 ++------------- 3 files changed, 140 insertions(+), 176 deletions(-) create mode 100644 apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewUtils.tsx diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewUtils.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewUtils.tsx new file mode 100644 index 00000000000..86d5b34a0df --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewUtils.tsx @@ -0,0 +1,114 @@ +/* + * 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 ReactNode } from "react"; +import { M_POLL_START, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix"; + +import { _t } from "../../../../languageHandler"; +import { MessagePreviewStore } from "../../../../stores/message-preview"; + +export interface EventPreviewContent { + previewContent: ReactNode; + previewTooltip?: string; +} + +export class EventPreviewContentCache { + private key?: string; + private content?: ReactNode; + + public get(preview: string, prefix: string | null): ReactNode { + const key = `${prefix ?? ""}\u0000${preview}`; + if (this.key === key && this.content !== undefined) { + return this.content; + } + + this.key = key; + this.content = prefix + ? _t( + "event_preview|preview", + { + prefix, + preview, + }, + { + bold: (sub) => {sub}, + }, + ) + : preview; + + return this.content; + } +} + +export class MatrixEventContentChangeListener { + private mxEvent?: MatrixEvent; + private callback?: () => void; + + public setEvent(mxEvent: MatrixEvent | undefined, callback: () => void): void { + if (this.mxEvent === mxEvent && this.callback === callback) return; + + this.teardown(); + this.mxEvent = mxEvent; + this.callback = callback; + + if (!mxEvent) return; + + mxEvent.on(MatrixEventEvent.Replaced, callback); + mxEvent.on(MatrixEventEvent.Decrypted, callback); + } + + public teardown(): void { + if (!this.mxEvent || !this.callback) { + this.mxEvent = undefined; + this.callback = undefined; + return; + } + + this.mxEvent.off(MatrixEventEvent.Replaced, this.callback); + this.mxEvent.off(MatrixEventEvent.Decrypted, this.callback); + this.mxEvent = undefined; + this.callback = undefined; + } +} + +export function getEventPreviewContent( + mxEvent: MatrixEvent, + cache: EventPreviewContentCache, +): EventPreviewContent | null { + const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent); + if (!preview) { + return null; + } + + const prefix = getEventPreviewPrefix(mxEvent.getType(), mxEvent.getContent().msgtype as MsgType | undefined); + + return { + previewContent: cache.get(preview, prefix), + previewTooltip: prefix ? undefined : preview, + }; +} + +function getEventPreviewPrefix(type: string, msgType?: MsgType): string | null { + switch (type) { + case M_POLL_START.name: + return _t("event_preview|prefix|poll"); + default: + } + + switch (msgType) { + case MsgType.Audio: + return _t("event_preview|prefix|audio"); + case MsgType.Image: + return _t("event_preview|prefix|image"); + case MsgType.Video: + return _t("event_preview|prefix|video"); + case MsgType.File: + return _t("event_preview|prefix|file"); + default: + return null; + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx index 7f28b017468..7cbdc94af26 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx @@ -5,8 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type ReactNode } from "react"; -import { M_POLL_START, type MatrixClient, type MatrixEvent, MatrixEventEvent, MsgType } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { BaseViewModel, @@ -14,8 +13,11 @@ import { type EventPreviewViewSnapshot, } from "@element-hq/web-shared-components"; -import { _t } from "../../../../languageHandler"; -import { MessagePreviewStore } from "../../../../stores/message-preview"; +import { + EventPreviewContentCache, + getEventPreviewContent, + MatrixEventContentChangeListener, +} from "./EventPreviewUtils"; export interface EventPreviewViewModelProps { /** @@ -32,11 +34,9 @@ export class EventPreviewViewModel extends BaseViewModel implements EventPreviewViewModelInterface { - private eventListenerCleanups: Array<() => void> = []; - private watchedEvent?: MatrixEvent; + private readonly eventContentListener = new MatrixEventContentChangeListener(); + private readonly previewContentCache = new EventPreviewContentCache(); private previewRequestId = 0; - private previewContentKey?: string; - private previewContent?: ReactNode; private static readonly hiddenSnapshot: EventPreviewViewSnapshot = { isVisible: false, @@ -45,8 +45,8 @@ export class EventPreviewViewModel public constructor(props: EventPreviewViewModelProps) { super(props, EventPreviewViewModel.hiddenSnapshot); - this.disposables.track(() => this.teardownEventListeners()); - this.setupEventListeners(props.mxEvent); + this.disposables.track(() => this.eventContentListener.teardown()); + this.eventContentListener.setEvent(props.mxEvent, this.onEventContentChanged); void this.updatePreview(); } @@ -57,7 +57,7 @@ export class EventPreviewViewModel ...this.props, mxEvent, }; - this.setupEventListeners(mxEvent); + this.eventContentListener.setEvent(mxEvent, this.onEventContentChanged); void this.updatePreview(); } @@ -71,30 +71,6 @@ export class EventPreviewViewModel void this.updatePreview(); } - private setupEventListeners(mxEvent?: MatrixEvent): void { - if (this.watchedEvent === mxEvent) return; - - this.teardownEventListeners(); - this.watchedEvent = mxEvent; - - if (!mxEvent) return; - - mxEvent.on(MatrixEventEvent.Replaced, this.onEventContentChanged); - mxEvent.on(MatrixEventEvent.Decrypted, this.onEventContentChanged); - this.eventListenerCleanups.push(() => { - mxEvent.off(MatrixEventEvent.Replaced, this.onEventContentChanged); - mxEvent.off(MatrixEventEvent.Decrypted, this.onEventContentChanged); - }); - } - - private teardownEventListeners(): void { - for (const cleanup of this.eventListenerCleanups) { - cleanup(); - } - this.eventListenerCleanups = []; - this.watchedEvent = undefined; - } - private onEventContentChanged = (): void => { void this.updatePreview(); }; @@ -125,21 +101,15 @@ export class EventPreviewViewModel return; } - const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent); - if (!preview) { + const previewContent = getEventPreviewContent(mxEvent, this.previewContentCache); + if (!previewContent) { this.setHidden(); return; } - const prefix = EventPreviewViewModel.getPreviewPrefix( - mxEvent.getType(), - mxEvent.getContent().msgtype as MsgType | undefined, - ); - this.snapshot.merge({ isVisible: true, - previewContent: this.getPreviewContent(preview, prefix), - previewTooltip: prefix ? undefined : preview, + ...previewContent, }); } @@ -159,48 +129,4 @@ export class EventPreviewViewModel previewTooltip: undefined, }); } - - private getPreviewContent(preview: string, prefix: string | null): ReactNode { - const key = `${prefix ?? ""}\u0000${preview}`; - if (this.previewContentKey === key && this.previewContent !== undefined) { - return this.previewContent; - } - - this.previewContentKey = key; - this.previewContent = prefix - ? _t( - "event_preview|preview", - { - prefix, - preview, - }, - { - bold: (sub) => {sub}, - }, - ) - : preview; - - return this.previewContent; - } - - private static getPreviewPrefix(type: string, msgType?: MsgType): string | null { - switch (type) { - case M_POLL_START.name: - return _t("event_preview|prefix|poll"); - default: - } - - switch (msgType) { - case MsgType.Audio: - return _t("event_preview|prefix|audio"); - case MsgType.Image: - return _t("event_preview|prefix|image"); - case MsgType.Video: - return _t("event_preview|prefix|video"); - case MsgType.File: - return _t("event_preview|prefix|file"); - default: - return null; - } - } } diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx index 23b8ed96863..20cd8d87cdd 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx +++ b/apps/web/src/viewmodels/room/timeline/event-tile/ThreadSummaryViewModel.tsx @@ -7,13 +7,10 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type MouseEvent, type ReactNode } from "react"; +import { type MouseEvent } from "react"; import { - M_POLL_START, type MatrixClient, type MatrixEvent, - MatrixEventEvent, - MsgType, type NotificationCount, RoomEvent, type Room, @@ -40,11 +37,15 @@ import { type ShowThreadPayload } from "../../../../dispatcher/payloads/ShowThre import PosthogTrackers from "../../../../PosthogTrackers"; import { determineUnreadState } from "../../../../RoomNotifs"; import { notificationLevelToIndicator } from "../../../../utils/notifications"; -import { MessagePreviewStore } from "../../../../stores/message-preview"; import { mediaFromMxc } from "../../../../customisations/Media"; import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; import { TimelineRenderingType } from "../../../../contexts/RoomContext"; import { keepIfSame } from "../../../../utils/keepIfSame"; +import { + EventPreviewContentCache, + getEventPreviewContent, + MatrixEventContentChangeListener, +} from "./EventPreviewUtils"; const AVATAR_SIZE_PX = 24; const THREAD_PROFILE_CONTEXTS = new Set([ @@ -92,14 +93,12 @@ export class ThreadMessagePreviewViewModel implements ThreadMessagePreviewViewModelInterface { private threadListenerCleanups: Array<() => void> = []; - private eventListenerCleanups: Array<() => void> = []; private memberListenerCleanups: Array<() => void> = []; - private watchedEvent?: MatrixEvent; + private readonly eventContentListener = new MatrixEventContentChangeListener(); + private readonly previewContentCache = new EventPreviewContentCache(); private watchedMemberRoom?: Room; private watchedMemberUserId?: string; private previewRequestId = 0; - private previewContentKey?: string; - private previewContent?: ReactNode; public constructor(props: ThreadMessagePreviewViewModelProps) { super(props, { @@ -113,7 +112,7 @@ export class ThreadMessagePreviewViewModel public dispose(): void { this.teardownThreadListener(); - this.teardownEventListeners(); + this.eventContentListener.teardown(); this.teardownMemberListener(); super.dispose(); } @@ -176,30 +175,6 @@ export class ThreadMessagePreviewViewModel this.threadListenerCleanups = []; } - private setupEventListeners(mxEvent?: MatrixEvent): void { - if (this.watchedEvent === mxEvent) return; - - this.teardownEventListeners(); - this.watchedEvent = mxEvent; - - if (!mxEvent) return; - - mxEvent.on(MatrixEventEvent.Replaced, this.onEventContentChanged); - mxEvent.on(MatrixEventEvent.Decrypted, this.onEventContentChanged); - this.eventListenerCleanups.push(() => { - mxEvent.off(MatrixEventEvent.Replaced, this.onEventContentChanged); - mxEvent.off(MatrixEventEvent.Decrypted, this.onEventContentChanged); - }); - } - - private teardownEventListeners(): void { - for (const cleanup of this.eventListenerCleanups) { - cleanup(); - } - this.eventListenerCleanups = []; - this.watchedEvent = undefined; - } - private setupMemberListener(mxEvent?: MatrixEvent): void { const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent); const room = this.getProfileRoom(); @@ -253,7 +228,7 @@ export class ThreadMessagePreviewViewModel private async updateFromThread(): Promise { const requestId = ++this.previewRequestId; const lastReply = this.props.thread.replyToEvent ?? undefined; - this.setupEventListeners(lastReply); + this.eventContentListener.setEvent(lastReply, this.onEventContentChanged); this.setupMemberListener(lastReply); if (!lastReply) { @@ -303,22 +278,15 @@ export class ThreadMessagePreviewViewModel return; } - const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent); + const preview = getEventPreviewContent(mxEvent, this.previewContentCache); if (!preview) { this.setHidden(); return; } - const prefix = ThreadMessagePreviewViewModel.getPreviewPrefix( - mxEvent.getType(), - mxEvent.getContent().msgtype as MsgType | undefined, - ); - const previewContent = this.getPreviewContent(preview, prefix); - this.snapshot.merge({ ...baseSnapshot, - previewContent, - previewTooltip: prefix ? undefined : preview, + ...preview, isVisible: true, }); } @@ -372,50 +340,6 @@ export class ThreadMessagePreviewViewModel }); } - private getPreviewContent(preview: string, prefix: string | null): ReactNode { - const key = `${prefix ?? ""}\u0000${preview}`; - if (this.previewContentKey === key) { - return this.previewContent; - } - - this.previewContentKey = key; - this.previewContent = prefix - ? _t( - "event_preview|preview", - { - prefix, - preview, - }, - { - bold: (sub) => {sub}, - }, - ) - : preview; - - return this.previewContent; - } - - private static getPreviewPrefix(type: string, msgType?: MsgType): string | null { - switch (type) { - case M_POLL_START.name: - return _t("event_preview|prefix|poll"); - default: - } - - switch (msgType) { - case MsgType.Audio: - return _t("event_preview|prefix|audio"); - case MsgType.Image: - return _t("event_preview|prefix|image"); - case MsgType.Video: - return _t("event_preview|prefix|video"); - case MsgType.File: - return _t("event_preview|prefix|file"); - default: - return null; - } - } - private static getDisplayMember( props: ThreadMessagePreviewViewModelProps, mxEvent: MatrixEvent,