Refactor EventPreview to shared MVVM (#33646)
* Refactor EventPreview to shared MVVM * Fix EventPreviewView export formatting * Snapshots images for stories * Deduplicate event preview formatting * Handle event preview update failures * Coalesce event preview updates * Wait for event preview test expectations
This commit is contained in:
@@ -255,7 +255,6 @@
|
|||||||
@import "./views/rooms/_EditMessageComposer.pcss";
|
@import "./views/rooms/_EditMessageComposer.pcss";
|
||||||
@import "./views/rooms/_EmojiButton.pcss";
|
@import "./views/rooms/_EmojiButton.pcss";
|
||||||
@import "./views/rooms/_EventBubbleTile.pcss";
|
@import "./views/rooms/_EventBubbleTile.pcss";
|
||||||
@import "./views/rooms/_EventPreview.pcss";
|
|
||||||
@import "./views/rooms/_EventTile.pcss";
|
@import "./views/rooms/_EventTile.pcss";
|
||||||
@import "./views/rooms/_HistoryTile.pcss";
|
@import "./views/rooms/_HistoryTile.pcss";
|
||||||
@import "./views/rooms/_IRCLayout.pcss";
|
@import "./views/rooms/_IRCLayout.pcss";
|
||||||
|
|||||||
@@ -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<HTMLSpanElement> {
|
|
||||||
/**
|
|
||||||
* 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 <EventPreviewTile {...props} preview={preview} className={className} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The props for the {@link EventPreviewTile} component.
|
|
||||||
*/
|
|
||||||
interface EventPreviewTileProps extends HTMLProps<HTMLSpanElement> {
|
|
||||||
/**
|
|
||||||
* 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 (
|
|
||||||
<span {...props} className={classes} title={preview}>
|
|
||||||
{preview}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span {...props} className={classes}>
|
|
||||||
{_t(
|
|
||||||
"event_preview|preview",
|
|
||||||
{
|
|
||||||
prefix,
|
|
||||||
preview,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
bold: (sub) => <span className="mx_EventPreview_prefix">{sub}</span>,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<IContent | undefined>(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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -62,10 +62,10 @@ import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadP
|
|||||||
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
import { UnreadNotificationBadge } from "./NotificationBadge/UnreadNotificationBadge";
|
||||||
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
import { getLateEventInfo } from "../../structures/grouper/LateEventGrouper";
|
||||||
import PinningUtils from "../../../utils/PinningUtils";
|
import PinningUtils from "../../../utils/PinningUtils";
|
||||||
import { EventPreview } from "./EventPreview";
|
|
||||||
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
|
import { ActionBarAdapter } from "./EventTile/ActionBarAdapter";
|
||||||
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
import { E2eStandardPadlockIcon } from "./EventTile/E2eStandardPadlockIcon";
|
||||||
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
|
import { E2eMessageSharedIconAdapter } from "./EventTile/E2eMessageSharedIconAdapter";
|
||||||
|
import { EventPreviewAdapter } from "./EventTile/EventPreviewAdapter";
|
||||||
import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter";
|
import { MessageTimestampAdapter } from "./EventTile/MessageTimestampAdapter";
|
||||||
import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter";
|
import { ReactionsRowAdapter } from "./EventTile/ReactionsRowAdapter";
|
||||||
import { ReceiptAdapter } from "./EventTile/ReceiptAdapter";
|
import { ReceiptAdapter } from "./EventTile/ReceiptAdapter";
|
||||||
@@ -1259,7 +1259,10 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
|||||||
) : this.props.mxEvent.isDecryptionFailure() ? (
|
) : this.props.mxEvent.isDecryptionFailure() ? (
|
||||||
<DecryptionFailureBodyFactory mxEvent={this.props.mxEvent} />
|
<DecryptionFailureBodyFactory mxEvent={this.props.mxEvent} />
|
||||||
) : (
|
) : (
|
||||||
<EventPreview mxEvent={this.props.mxEvent} />
|
<EventPreviewAdapter
|
||||||
|
eventTileViewModel={this.viewModel}
|
||||||
|
mxEvent={this.props.mxEvent}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{this.renderThreadPanelSummary(threadState)}
|
{this.renderThreadPanelSummary(threadState)}
|
||||||
|
|||||||
@@ -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, { useEffect, type JSX } from "react";
|
||||||
|
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||||
|
import { EventPreviewView } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||||
|
import { type EventTileViewModel } from "../../../../viewmodels/room/timeline/event-tile/EventTileViewModel";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props for the {@link EventPreviewAdapter} component.
|
||||||
|
*/
|
||||||
|
interface EventPreviewAdapterProps extends Omit<React.ComponentPropsWithoutRef<"span">, "children" | "title"> {
|
||||||
|
/** View model backing the event tile. */
|
||||||
|
eventTileViewModel: EventTileViewModel;
|
||||||
|
/** Matrix event whose preview is rendered. */
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an event preview inside notification timeline tiles.
|
||||||
|
*/
|
||||||
|
export function EventPreviewAdapter({
|
||||||
|
eventTileViewModel,
|
||||||
|
mxEvent,
|
||||||
|
...props
|
||||||
|
}: Readonly<EventPreviewAdapterProps>): JSX.Element {
|
||||||
|
const cli = useMatrixClientContext();
|
||||||
|
const vm = eventTileViewModel.getEventPreviewViewModel({ cli, mxEvent });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// This child VM owns Matrix listeners, so release it when the view using it leaves the tree.
|
||||||
|
return () => eventTileViewModel.releaseEventPreviewViewModel();
|
||||||
|
}, [eventTileViewModel]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setClient(cli);
|
||||||
|
}, [cli, vm]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
vm.setEvent(mxEvent);
|
||||||
|
}, [mxEvent, vm]);
|
||||||
|
|
||||||
|
return <EventPreviewView {...props} vm={vm} />;
|
||||||
|
}
|
||||||
@@ -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 { Button } from "@vector-im/compound-web";
|
||||||
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
|
import { type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
import { EventPreviewView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents";
|
import { usePinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||||
import { _t } from "../../../languageHandler";
|
import { _t } from "../../../languageHandler";
|
||||||
@@ -24,8 +25,9 @@ import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPaylo
|
|||||||
import { Action } from "../../../dispatcher/actions";
|
import { Action } from "../../../dispatcher/actions";
|
||||||
import MessageEvent from "../messages/MessageEvent";
|
import MessageEvent from "../messages/MessageEvent";
|
||||||
import PosthogTrackers from "../../../PosthogTrackers.ts";
|
import PosthogTrackers from "../../../PosthogTrackers.ts";
|
||||||
import { EventPreview } from "./EventPreview.tsx";
|
|
||||||
import { SDKContext } from "../../../contexts/SDKContext.ts";
|
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.
|
* The props for the {@link PinnedMessageBanner} component.
|
||||||
@@ -118,7 +120,7 @@ export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<EventPreview
|
<EventPreviewWrapper
|
||||||
mxEvent={pinnedEvent}
|
mxEvent={pinnedEvent}
|
||||||
className="mx_PinnedMessageBanner_message"
|
className="mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
@@ -141,6 +143,25 @@ export function PinnedMessageBanner({ room, permalinkCreator }: PinnedMessageBan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type EventPreviewWrapperProps = Omit<React.ComponentPropsWithoutRef<"span">, "children" | "title"> & {
|
||||||
|
mxEvent: MatrixEvent;
|
||||||
|
};
|
||||||
|
|
||||||
|
function EventPreviewWrapper({ mxEvent, ...props }: Readonly<EventPreviewWrapperProps>): 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 <EventPreviewView {...props} vm={vm} />;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When the banner is displayed or hidden, we want to notify the timeline to resize itself.
|
* When the banner is displayed or hidden, we want to notify the timeline to resize itself.
|
||||||
* @param pinnedEvent
|
* @param pinnedEvent
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
/*
|
||||||
|
* 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 { Disposables } from "@element-hq/web-shared-components";
|
||||||
|
|
||||||
|
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) => <strong>{sub}</strong>,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: preview;
|
||||||
|
|
||||||
|
return this.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MatrixEventContentChangeListener {
|
||||||
|
private mxEvent?: MatrixEvent;
|
||||||
|
private callback?: () => void;
|
||||||
|
private disposables?: Disposables;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
this.disposables = new Disposables();
|
||||||
|
this.disposables.trackListener(mxEvent, MatrixEventEvent.Replaced, callback);
|
||||||
|
this.disposables.trackListener(mxEvent, MatrixEventEvent.Decrypted, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
public teardown(): void {
|
||||||
|
this.disposables?.dispose();
|
||||||
|
this.disposables = undefined;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* 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<EventPreviewViewSnapshot, EventPreviewViewModelProps>
|
||||||
|
implements EventPreviewViewModelInterface
|
||||||
|
{
|
||||||
|
private readonly eventContentListener = new MatrixEventContentChangeListener();
|
||||||
|
private readonly previewContentCache = new EventPreviewContentCache();
|
||||||
|
private previewRequestId = 0;
|
||||||
|
private updatePreviewInFlight = false;
|
||||||
|
private updatePreviewQueued = false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.updatePreviewSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setEvent(mxEvent?: MatrixEvent): void {
|
||||||
|
if (this.props.mxEvent === mxEvent) return;
|
||||||
|
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
mxEvent,
|
||||||
|
};
|
||||||
|
this.eventContentListener.setEvent(mxEvent, this.onEventContentChanged);
|
||||||
|
this.updatePreviewSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
public setClient(cli: MatrixClient): void {
|
||||||
|
if (this.props.cli === cli) return;
|
||||||
|
|
||||||
|
this.props = {
|
||||||
|
...this.props,
|
||||||
|
cli,
|
||||||
|
};
|
||||||
|
this.updatePreviewSafely();
|
||||||
|
}
|
||||||
|
|
||||||
|
private onEventContentChanged = (): void => {
|
||||||
|
this.updatePreviewSafely();
|
||||||
|
};
|
||||||
|
|
||||||
|
private updatePreviewSafely(): void {
|
||||||
|
if (this.updatePreviewInFlight) {
|
||||||
|
this.updatePreviewQueued = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void this.drainPreviewUpdates();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drainPreviewUpdates(): Promise<void> {
|
||||||
|
this.updatePreviewInFlight = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
this.updatePreviewQueued = false;
|
||||||
|
try {
|
||||||
|
await this.updatePreview();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to update event preview", error);
|
||||||
|
}
|
||||||
|
} while (this.updatePreviewQueued && !this.isDisposed);
|
||||||
|
} finally {
|
||||||
|
this.updatePreviewInFlight = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updatePreview(): Promise<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
E2eMessageSharedIconViewModel,
|
E2eMessageSharedIconViewModel,
|
||||||
type E2eMessageSharedIconViewModelProps,
|
type E2eMessageSharedIconViewModelProps,
|
||||||
} from "./E2eMessageSharedIconViewModel";
|
} from "./E2eMessageSharedIconViewModel";
|
||||||
|
import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel";
|
||||||
import {
|
import {
|
||||||
ThreadListActionBarViewModel,
|
ThreadListActionBarViewModel,
|
||||||
type ThreadListActionBarViewModelProps,
|
type ThreadListActionBarViewModelProps,
|
||||||
@@ -301,6 +302,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
private threadSummaryViewModel?: ThreadSummaryViewModel;
|
private threadSummaryViewModel?: ThreadSummaryViewModel;
|
||||||
private threadListActionBarViewModel?: ThreadListActionBarViewModel;
|
private threadListActionBarViewModel?: ThreadListActionBarViewModel;
|
||||||
private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel;
|
private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel;
|
||||||
|
private eventPreviewViewModel?: EventPreviewViewModel;
|
||||||
private actionBarViewModel?: EventTileActionBarViewModel;
|
private actionBarViewModel?: EventTileActionBarViewModel;
|
||||||
private reactionsRowViewModel?: ReactionsRowViewModel;
|
private reactionsRowViewModel?: ReactionsRowViewModel;
|
||||||
|
|
||||||
@@ -323,6 +325,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
this.threadSummaryViewModel?.dispose();
|
this.threadSummaryViewModel?.dispose();
|
||||||
this.threadListActionBarViewModel?.dispose();
|
this.threadListActionBarViewModel?.dispose();
|
||||||
this.e2eMessageSharedIconViewModel?.dispose();
|
this.e2eMessageSharedIconViewModel?.dispose();
|
||||||
|
this.eventPreviewViewModel?.dispose();
|
||||||
this.actionBarViewModel?.dispose();
|
this.actionBarViewModel?.dispose();
|
||||||
this.reactionsRowViewModel?.dispose();
|
this.reactionsRowViewModel?.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -382,6 +385,18 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
|||||||
this.e2eMessageSharedIconViewModel = undefined;
|
this.e2eMessageSharedIconViewModel = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lazily creates and returns the event preview child view model. */
|
||||||
|
public getEventPreviewViewModel(props: EventPreviewViewModelProps): EventPreviewViewModel {
|
||||||
|
this.eventPreviewViewModel ??= new EventPreviewViewModel(props);
|
||||||
|
return this.eventPreviewViewModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Releases the event preview child view model when its adapter unmounts. */
|
||||||
|
public releaseEventPreviewViewModel(): void {
|
||||||
|
this.eventPreviewViewModel?.dispose();
|
||||||
|
this.eventPreviewViewModel = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/** Lazily creates and returns the event action bar child view model. */
|
/** Lazily creates and returns the event action bar child view model. */
|
||||||
public getActionBarViewModel(props: EventTileActionBarViewModelProps): EventTileActionBarViewModel {
|
public getActionBarViewModel(props: EventTileActionBarViewModelProps): EventTileActionBarViewModel {
|
||||||
this.actionBarViewModel ??= new EventTileActionBarViewModel(props);
|
this.actionBarViewModel ??= new EventTileActionBarViewModel(props);
|
||||||
|
|||||||
@@ -7,13 +7,10 @@
|
|||||||
* Please see LICENSE files in the repository root for full details.
|
* 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 {
|
import {
|
||||||
M_POLL_START,
|
|
||||||
type MatrixClient,
|
type MatrixClient,
|
||||||
type MatrixEvent,
|
type MatrixEvent,
|
||||||
MatrixEventEvent,
|
|
||||||
MsgType,
|
|
||||||
type NotificationCount,
|
type NotificationCount,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
type Room,
|
type Room,
|
||||||
@@ -40,11 +37,15 @@ import { type ShowThreadPayload } from "../../../../dispatcher/payloads/ShowThre
|
|||||||
import PosthogTrackers from "../../../../PosthogTrackers";
|
import PosthogTrackers from "../../../../PosthogTrackers";
|
||||||
import { determineUnreadState } from "../../../../RoomNotifs";
|
import { determineUnreadState } from "../../../../RoomNotifs";
|
||||||
import { notificationLevelToIndicator } from "../../../../utils/notifications";
|
import { notificationLevelToIndicator } from "../../../../utils/notifications";
|
||||||
import { MessagePreviewStore } from "../../../../stores/message-preview";
|
|
||||||
import { mediaFromMxc } from "../../../../customisations/Media";
|
import { mediaFromMxc } from "../../../../customisations/Media";
|
||||||
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier";
|
||||||
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
import { TimelineRenderingType } from "../../../../contexts/RoomContext";
|
||||||
import { keepIfSame } from "../../../../utils/keepIfSame";
|
import { keepIfSame } from "../../../../utils/keepIfSame";
|
||||||
|
import {
|
||||||
|
EventPreviewContentCache,
|
||||||
|
getEventPreviewContent,
|
||||||
|
MatrixEventContentChangeListener,
|
||||||
|
} from "./EventPreviewUtils";
|
||||||
|
|
||||||
const AVATAR_SIZE_PX = 24;
|
const AVATAR_SIZE_PX = 24;
|
||||||
const THREAD_PROFILE_CONTEXTS = new Set<TimelineRenderingType>([
|
const THREAD_PROFILE_CONTEXTS = new Set<TimelineRenderingType>([
|
||||||
@@ -92,14 +93,12 @@ export class ThreadMessagePreviewViewModel
|
|||||||
implements ThreadMessagePreviewViewModelInterface
|
implements ThreadMessagePreviewViewModelInterface
|
||||||
{
|
{
|
||||||
private threadListenerCleanups: Array<() => void> = [];
|
private threadListenerCleanups: Array<() => void> = [];
|
||||||
private eventListenerCleanups: Array<() => void> = [];
|
|
||||||
private memberListenerCleanups: Array<() => void> = [];
|
private memberListenerCleanups: Array<() => void> = [];
|
||||||
private watchedEvent?: MatrixEvent;
|
private readonly eventContentListener = new MatrixEventContentChangeListener();
|
||||||
|
private readonly previewContentCache = new EventPreviewContentCache();
|
||||||
private watchedMemberRoom?: Room;
|
private watchedMemberRoom?: Room;
|
||||||
private watchedMemberUserId?: string;
|
private watchedMemberUserId?: string;
|
||||||
private previewRequestId = 0;
|
private previewRequestId = 0;
|
||||||
private previewContentKey?: string;
|
|
||||||
private previewContent?: ReactNode;
|
|
||||||
|
|
||||||
public constructor(props: ThreadMessagePreviewViewModelProps) {
|
public constructor(props: ThreadMessagePreviewViewModelProps) {
|
||||||
super(props, {
|
super(props, {
|
||||||
@@ -113,7 +112,7 @@ export class ThreadMessagePreviewViewModel
|
|||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
this.teardownThreadListener();
|
this.teardownThreadListener();
|
||||||
this.teardownEventListeners();
|
this.eventContentListener.teardown();
|
||||||
this.teardownMemberListener();
|
this.teardownMemberListener();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -176,30 +175,6 @@ export class ThreadMessagePreviewViewModel
|
|||||||
this.threadListenerCleanups = [];
|
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 {
|
private setupMemberListener(mxEvent?: MatrixEvent): void {
|
||||||
const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent);
|
const userId = ThreadMessagePreviewViewModel.getProfileUserId(mxEvent);
|
||||||
const room = this.getProfileRoom();
|
const room = this.getProfileRoom();
|
||||||
@@ -253,7 +228,7 @@ export class ThreadMessagePreviewViewModel
|
|||||||
private async updateFromThread(): Promise<void> {
|
private async updateFromThread(): Promise<void> {
|
||||||
const requestId = ++this.previewRequestId;
|
const requestId = ++this.previewRequestId;
|
||||||
const lastReply = this.props.thread.replyToEvent ?? undefined;
|
const lastReply = this.props.thread.replyToEvent ?? undefined;
|
||||||
this.setupEventListeners(lastReply);
|
this.eventContentListener.setEvent(lastReply, this.onEventContentChanged);
|
||||||
this.setupMemberListener(lastReply);
|
this.setupMemberListener(lastReply);
|
||||||
|
|
||||||
if (!lastReply) {
|
if (!lastReply) {
|
||||||
@@ -303,22 +278,15 @@ export class ThreadMessagePreviewViewModel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preview = MessagePreviewStore.instance.generatePreviewForEvent(mxEvent);
|
const preview = getEventPreviewContent(mxEvent, this.previewContentCache);
|
||||||
if (!preview) {
|
if (!preview) {
|
||||||
this.setHidden();
|
this.setHidden();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = ThreadMessagePreviewViewModel.getPreviewPrefix(
|
|
||||||
mxEvent.getType(),
|
|
||||||
mxEvent.getContent().msgtype as MsgType | undefined,
|
|
||||||
);
|
|
||||||
const previewContent = this.getPreviewContent(preview, prefix);
|
|
||||||
|
|
||||||
this.snapshot.merge({
|
this.snapshot.merge({
|
||||||
...baseSnapshot,
|
...baseSnapshot,
|
||||||
previewContent,
|
...preview,
|
||||||
previewTooltip: prefix ? undefined : preview,
|
|
||||||
isVisible: true,
|
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) => <strong>{sub}</strong>,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: 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(
|
private static getDisplayMember(
|
||||||
props: ThreadMessagePreviewViewModelProps,
|
props: ThreadMessagePreviewViewModelProps,
|
||||||
mxEvent: MatrixEvent,
|
mxEvent: MatrixEvent,
|
||||||
|
|||||||
+19
-33
@@ -40,15 +40,13 @@ exports[`<PinnedMessageBanner /> should display display a poll event 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span
|
<strong>
|
||||||
class="mx_EventPreview_prefix"
|
|
||||||
>
|
|
||||||
Poll:
|
Poll:
|
||||||
</span>
|
</strong>
|
||||||
Alice?
|
Alice?
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -119,9 +117,8 @@ exports[`<PinnedMessageBanner /> should display the last message when the pinned
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
title="Third pinned message"
|
|
||||||
>
|
>
|
||||||
Third pinned message
|
Third pinned message
|
||||||
</span>
|
</span>
|
||||||
@@ -180,15 +177,13 @@ exports[`<PinnedMessageBanner /> should display the m.audio event type 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span
|
<strong>
|
||||||
class="mx_EventPreview_prefix"
|
|
||||||
>
|
|
||||||
Audio:
|
Audio:
|
||||||
</span>
|
</strong>
|
||||||
Message with m.audio type
|
Message with m.audio type
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -238,15 +233,13 @@ exports[`<PinnedMessageBanner /> should display the m.file event type 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span
|
<strong>
|
||||||
class="mx_EventPreview_prefix"
|
|
||||||
>
|
|
||||||
File:
|
File:
|
||||||
</span>
|
</strong>
|
||||||
Message with m.file type
|
Message with m.file type
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -296,15 +289,13 @@ exports[`<PinnedMessageBanner /> should display the m.image event type 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span
|
<strong>
|
||||||
class="mx_EventPreview_prefix"
|
|
||||||
>
|
|
||||||
Image:
|
Image:
|
||||||
</span>
|
</strong>
|
||||||
Message with m.image type
|
Message with m.image type
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -354,15 +345,13 @@ exports[`<PinnedMessageBanner /> should display the m.video event type 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
<span
|
<strong>
|
||||||
class="mx_EventPreview_prefix"
|
|
||||||
>
|
|
||||||
Video:
|
Video:
|
||||||
</span>
|
</strong>
|
||||||
Message with m.video type
|
Message with m.video type
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -429,9 +418,8 @@ exports[`<PinnedMessageBanner /> should render 2 pinned event 1`] = `
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
title="Second pinned message"
|
|
||||||
>
|
>
|
||||||
Second pinned message
|
Second pinned message
|
||||||
</span>
|
</span>
|
||||||
@@ -511,9 +499,8 @@ exports[`<PinnedMessageBanner /> should render 4 pinned event 1`] = `
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
title="Fourth pinned message"
|
|
||||||
>
|
>
|
||||||
Fourth pinned message
|
Fourth pinned message
|
||||||
</span>
|
</span>
|
||||||
@@ -572,9 +559,8 @@ exports[`<PinnedMessageBanner /> should render a single pinned event 1`] = `
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<span
|
<span
|
||||||
class="mx_EventPreview mx_PinnedMessageBanner_message"
|
class="mx_EventPreview _eventPreview_w0s50_8 mx_PinnedMessageBanner_message"
|
||||||
data-testid="banner-message"
|
data-testid="banner-message"
|
||||||
title="First pinned message"
|
|
||||||
>
|
>
|
||||||
First pinned message
|
First pinned message
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
/*
|
||||||
|
* 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, waitFor } 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 { mkEvent, stubClient } from "../../../../../test-utils";
|
||||||
|
|
||||||
|
describe("EventPreviewViewModel", () => {
|
||||||
|
const roomId = "!room:example.com";
|
||||||
|
const userId = "@alice:example.com";
|
||||||
|
|
||||||
|
let cli: MatrixClient;
|
||||||
|
let decryptEventIfNeeded: jest.SpiedFunction<MatrixClient["decryptEventIfNeeded"]>;
|
||||||
|
|
||||||
|
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 waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Text preview"));
|
||||||
|
|
||||||
|
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 waitFor(() => expect(vm.getSnapshot().previewContent).toBeDefined());
|
||||||
|
|
||||||
|
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 waitFor(() => 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 waitFor(() => 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 waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Original"));
|
||||||
|
decryptEventIfNeeded.mockClear();
|
||||||
|
vm.subscribe(listener);
|
||||||
|
|
||||||
|
vm.setEvent(originalEvent);
|
||||||
|
vm.setClient(cli);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(decryptEventIfNeeded).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vm.setEvent(updatedEvent);
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Updated"));
|
||||||
|
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes event listeners when disposed", async () => {
|
||||||
|
const mxEvent = makeMessageEvent({ body: "Original" });
|
||||||
|
const vm = new EventPreviewViewModel({ cli, mxEvent });
|
||||||
|
const listener = jest.fn();
|
||||||
|
|
||||||
|
await waitFor(() => expect(vm.getSnapshot().previewContent).toBe("Original"));
|
||||||
|
decryptEventIfNeeded.mockClear();
|
||||||
|
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);
|
||||||
|
|
||||||
|
expect(listener).not.toHaveBeenCalled();
|
||||||
|
expect(decryptEventIfNeeded).not.toHaveBeenCalled();
|
||||||
|
expect(vm.getSnapshot().previewContent).toBe("Original");
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
@@ -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/E2eMessageSharedIcon";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/E2ePadlock";
|
export * from "./room/timeline/event-tile/EventTileView/E2ePadlock";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView";
|
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/call";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/EventTileBubble";
|
export * from "./room/timeline/event-tile/EventTileView/EventTileBubble";
|
||||||
export * from "./room/timeline/event-tile/EventTileView/MJitsiWidgetEventView";
|
export * from "./room/timeline/event-tile/EventTileView/MJitsiWidgetEventView";
|
||||||
|
|||||||
+5
-6
@@ -1,18 +1,17 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright 2024 New Vector Ltd.
|
* Copyright 2026 Element Creations 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
|
* 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.
|
* Please see LICENSE files in the repository root for full details.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
.mx_EventPreview {
|
.eventPreview {
|
||||||
font: var(--cpd-font-body-sm-regular);
|
font: var(--cpd-font-body-sm-regular);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
.mx_EventPreview_prefix {
|
.eventPreview strong {
|
||||||
font: var(--cpd-font-body-sm-semibold);
|
font: var(--cpd-font-body-sm-semibold);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+58
@@ -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 <EventPreviewView vm={vm} className={className} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
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<typeof EventPreviewViewWrapper>;
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof meta>;
|
||||||
|
|
||||||
|
export const Default: Story = {};
|
||||||
|
|
||||||
|
export const WithPrefix: Story = {
|
||||||
|
args: {
|
||||||
|
previewContent: (
|
||||||
|
<>
|
||||||
|
<strong>Image:</strong> city-map.png
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
previewTooltip: undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Hidden: Story = {
|
||||||
|
args: {
|
||||||
|
isVisible: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
+50
@@ -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(<Default />);
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a prefixed event preview", () => {
|
||||||
|
const { container } = render(<WithPrefix />);
|
||||||
|
|
||||||
|
expect(container).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not render when hidden", () => {
|
||||||
|
render(<Hidden />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("A short text message preview")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies custom span props without using native title tooltips", () => {
|
||||||
|
const vm = new MockViewModel<EventPreviewViewSnapshot>({
|
||||||
|
isVisible: true,
|
||||||
|
previewContent: "Preview text",
|
||||||
|
previewTooltip: "Preview text",
|
||||||
|
}) as EventPreviewViewModel;
|
||||||
|
|
||||||
|
render(<EventPreviewView vm={vm} className="custom-preview" data-testid="event-preview" />);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("event-preview")).toHaveClass("mx_EventPreview", "custom-preview");
|
||||||
|
expect(screen.getByTestId("event-preview")).not.toHaveAttribute("title");
|
||||||
|
});
|
||||||
|
});
|
||||||
+60
@@ -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<EventPreviewViewSnapshot>;
|
||||||
|
|
||||||
|
type EventPreviewViewProps = Omit<ComponentPropsWithoutRef<"span">, "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<EventPreviewViewProps>): JSX.Element {
|
||||||
|
const { isVisible, previewContent, previewTooltip } = useViewModel(vm);
|
||||||
|
|
||||||
|
if (!isVisible || !previewContent) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = (
|
||||||
|
<span {...props} className={classNames("mx_EventPreview", styles.eventPreview, className)}>
|
||||||
|
{previewContent}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!previewTooltip) {
|
||||||
|
return preview;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tooltip description={previewTooltip}>{preview}</Tooltip>;
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
|
exports[`EventPreviewView > renders a prefixed event preview 1`] = `
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mx_EventPreview EventPreviewView-module_eventPreview"
|
||||||
|
>
|
||||||
|
<strong>
|
||||||
|
Image:
|
||||||
|
</strong>
|
||||||
|
city-map.png
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`EventPreviewView > renders the default event preview 1`] = `
|
||||||
|
<div>
|
||||||
|
<span
|
||||||
|
class="mx_EventPreview EventPreviewView-module_eventPreview"
|
||||||
|
>
|
||||||
|
A short text message preview
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
+8
@@ -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";
|
||||||
Reference in New Issue
Block a user