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 907612b0ce4..dbe64aa8440 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"; @@ -146,6 +146,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"; @@ -1308,7 +1309,7 @@ export class UnwrappedEventTile extends React.Component ) : this.props.mxEvent.isDecryptionFailure() ? ( ) : ( - + )} {this.renderThreadPanelSummary(threadState)} @@ -1571,6 +1572,25 @@ function MessageTimestampAdapter({ vm, timestampProps }: Readonly, "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/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 new file mode 100644 index 00000000000..7cbdc94af26 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/EventPreviewViewModel.tsx @@ -0,0 +1,132 @@ +/* + * 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 { type MatrixClient, type MatrixEvent } 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 { + EventPreviewContentCache, + getEventPreviewContent, + MatrixEventContentChangeListener, +} from "./EventPreviewUtils"; + +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 readonly eventContentListener = new MatrixEventContentChangeListener(); + private readonly previewContentCache = new EventPreviewContentCache(); + private previewRequestId = 0; + + private static readonly hiddenSnapshot: EventPreviewViewSnapshot = { + isVisible: false, + }; + + public constructor(props: EventPreviewViewModelProps) { + super(props, EventPreviewViewModel.hiddenSnapshot); + + this.disposables.track(() => this.eventContentListener.teardown()); + this.eventContentListener.setEvent(props.mxEvent, this.onEventContentChanged); + void this.updatePreview(); + } + + public setEvent(mxEvent?: MatrixEvent): void { + if (this.props.mxEvent === mxEvent) return; + + this.props = { + ...this.props, + mxEvent, + }; + this.eventContentListener.setEvent(mxEvent, this.onEventContentChanged); + void this.updatePreview(); + } + + public setClient(cli: MatrixClient): void { + if (this.props.cli === cli) return; + + this.props = { + ...this.props, + cli, + }; + void this.updatePreview(); + } + + 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 previewContent = getEventPreviewContent(mxEvent, this.previewContentCache); + if (!previewContent) { + this.setHidden(); + return; + } + + this.snapshot.merge({ + isVisible: true, + ...previewContent, + }); + } + + 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, + }); + } +} 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, 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/__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 00000000000..b4718dac915 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/hidden-auto.png new file mode 100644 index 00000000000..17fe39394f5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/hidden-auto.png differ 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 00000000000..e01c2844b93 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/EventTileView/EventPreviewView/EventPreviewView.stories.tsx/with-prefix-auto.png differ 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..58f422d4e19 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/EventTileView/EventPreviewView/index.tsx @@ -0,0 +1,8 @@ +/* + * 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";