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:
@@ -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,
|
||||
type E2eMessageSharedIconViewModelProps,
|
||||
} from "./E2eMessageSharedIconViewModel";
|
||||
import { EventPreviewViewModel, type EventPreviewViewModelProps } from "./EventPreviewViewModel";
|
||||
import {
|
||||
ThreadListActionBarViewModel,
|
||||
type ThreadListActionBarViewModelProps,
|
||||
@@ -301,6 +302,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
||||
private threadSummaryViewModel?: ThreadSummaryViewModel;
|
||||
private threadListActionBarViewModel?: ThreadListActionBarViewModel;
|
||||
private e2eMessageSharedIconViewModel?: E2eMessageSharedIconViewModel;
|
||||
private eventPreviewViewModel?: EventPreviewViewModel;
|
||||
private actionBarViewModel?: EventTileActionBarViewModel;
|
||||
private reactionsRowViewModel?: ReactionsRowViewModel;
|
||||
|
||||
@@ -323,6 +325,7 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
||||
this.threadSummaryViewModel?.dispose();
|
||||
this.threadListActionBarViewModel?.dispose();
|
||||
this.e2eMessageSharedIconViewModel?.dispose();
|
||||
this.eventPreviewViewModel?.dispose();
|
||||
this.actionBarViewModel?.dispose();
|
||||
this.reactionsRowViewModel?.dispose();
|
||||
super.dispose();
|
||||
@@ -382,6 +385,18 @@ export class EventTileViewModel extends BaseViewModel<EventTileRenderState, Even
|
||||
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. */
|
||||
public getActionBarViewModel(props: EventTileActionBarViewModelProps): EventTileActionBarViewModel {
|
||||
this.actionBarViewModel ??= new EventTileActionBarViewModel(props);
|
||||
|
||||
@@ -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<TimelineRenderingType>([
|
||||
@@ -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<void> {
|
||||
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) => <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(
|
||||
props: ThreadMessagePreviewViewModelProps,
|
||||
mxEvent: MatrixEvent,
|
||||
|
||||
Reference in New Issue
Block a user