diff --git a/apps/web/src/stores/message-preview/MessagePreviewStore.ts b/apps/web/src/stores/message-preview/MessagePreviewStore.ts index a0fced0193..e9b5c59007 100644 --- a/apps/web/src/stores/message-preview/MessagePreviewStore.ts +++ b/apps/web/src/stores/message-preview/MessagePreviewStore.ts @@ -37,47 +37,6 @@ import SettingsStore from "../../settings/SettingsStore"; // the change happened. const ROOM_PREVIEW_CHANGED = "room_preview_changed"; -const PREVIEWS: Record< - string, - { - isState: boolean; - previewer: Preview; - } -> = { - "m.room.message": { - isState: false, - previewer: new MessageEventPreview(), - }, - "m.call.invite": { - isState: false, - previewer: new LegacyCallInviteEventPreview(), - }, - "m.call.answer": { - isState: false, - previewer: new LegacyCallAnswerEventPreview(), - }, - "m.call.hangup": { - isState: false, - previewer: new LegacyCallHangupEvent(), - }, - "m.sticker": { - isState: false, - previewer: new StickerEventPreview(), - }, - "m.reaction": { - isState: false, - previewer: new ReactionEventPreview(), - }, - [M_POLL_START.name]: { - isState: false, - previewer: new PollStartEventPreview(), - }, - [M_POLL_START.altName]: { - isState: false, - previewer: new PollStartEventPreview(), - }, -}; - // The maximum number of events we're willing to look back on to get a preview. const MAX_EVENTS_BACKWARDS = 50; @@ -139,8 +98,23 @@ export class MessagePreviewStore extends AsyncStoreWithClient { // null indicates the preview is empty / irrelevant private previews = new Map>(); + // Previewers keyed by event type + private readonly previewers: Record; + private constructor() { super(defaultDispatcher, {}); + this.previewers = { + "m.room.message": { isState: false, previewer: new MessageEventPreview() }, + "m.call.invite": { isState: false, previewer: new LegacyCallInviteEventPreview() }, + "m.call.answer": { isState: false, previewer: new LegacyCallAnswerEventPreview() }, + "m.call.hangup": { isState: false, previewer: new LegacyCallHangupEvent() }, + "m.sticker": { isState: false, previewer: new StickerEventPreview() }, + // ReactionEventPreview needs the message preview store, so the previewers are constructed here (passing `this`) rather than at + // module scope, where the store singleton would not exist yet (circular dependency). + "m.reaction": { isState: false, previewer: new ReactionEventPreview(this) }, + [M_POLL_START.name]: { isState: false, previewer: new PollStartEventPreview() }, + [M_POLL_START.altName]: { isState: false, previewer: new PollStartEventPreview() }, + }; } public static get instance(): MessagePreviewStore { @@ -172,7 +146,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { } public generatePreviewForEvent(event: MatrixEvent): string { - const previewDef = PREVIEWS[event.getType()]; + const previewDef = this.previewers[event.getType()]; return previewDef?.previewer.getTextFor(event, undefined, true) ?? ""; } @@ -217,7 +191,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { await this.matrixClient?.decryptEventIfNeeded(event); const shouldHide = shouldHideEvent(event); if (shouldHide) continue; - const previewDef = PREVIEWS[event.getType()]; + const previewDef = this.previewers[event.getType()]; if (!previewDef) continue; if (previewDef.isState && isNullOrUndefined(event.getStateKey())) continue; diff --git a/apps/web/src/stores/message-preview/previews/ReactionEventPreview.ts b/apps/web/src/stores/message-preview/previews/ReactionEventPreview.ts index 8b405f2fc8..f886059a2e 100644 --- a/apps/web/src/stores/message-preview/previews/ReactionEventPreview.ts +++ b/apps/web/src/stores/message-preview/previews/ReactionEventPreview.ts @@ -13,9 +13,11 @@ import { type TagID } from "../../room-list-v3/skip-list/tag"; import { getSenderName, isSelf } from "./utils"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { MessagePreviewStore } from "../MessagePreviewStore"; +import type { MessagePreviewStore } from "../MessagePreviewStore"; export class ReactionEventPreview implements Preview { + public constructor(private readonly messagePreviewStore: MessagePreviewStore) {} + public getTextFor(event: MatrixEvent, tagId?: TagID, isThread?: boolean): string | null { const roomId = event.getRoomId(); if (!roomId) return null; // not a room event @@ -31,7 +33,7 @@ export class ReactionEventPreview implements Preview { const relatedEvent = relation.event_id ? room?.findEventById(relation.event_id) : null; if (!relatedEvent) return null; - const message = MessagePreviewStore.instance.generatePreviewForEvent(relatedEvent); + const message = this.messagePreviewStore.generatePreviewForEvent(relatedEvent); if (isSelf(event)) { return _t("event_preview|m.reaction|you", { reaction, diff --git a/apps/web/test/unit-tests/stores/message-preview/MessagePreviewStore-test.ts b/apps/web/test/unit-tests/stores/message-preview/MessagePreviewStore-test.ts index 89d87d362b..6b7c2866b6 100644 --- a/apps/web/test/unit-tests/stores/message-preview/MessagePreviewStore-test.ts +++ b/apps/web/test/unit-tests/stores/message-preview/MessagePreviewStore-test.ts @@ -11,6 +11,9 @@ import { EventStatus, EventTimeline, EventType, + M_POLL_KIND_DISCLOSED, + M_POLL_START, + M_TEXT, type MatrixClient, type MatrixEvent, PendingEventOrdering, @@ -373,4 +376,82 @@ describe("MessagePreviewStore", () => { // @ts-ignore private access expect(store.previews.has(nonRenderedRoom.roomId)).toBeFalsy(); }); + + describe("generatePreviewForEvent", () => { + it("should generate a preview for a message event", () => { + const message = mkMessage({ + user: "@sender:server", + event: true, + room: room.roomId, + msg: "Hello world", + }); + + expect(store.generatePreviewForEvent(message)).toBe("Hello world"); + }); + + it("should generate a preview for a sticker event", () => { + const sticker = mkEvent({ + event: true, + type: EventType.Sticker, + user: "@sender:server", + room: room.roomId, + content: { body: "A sticker" }, + }); + + expect(store.generatePreviewForEvent(sticker)).toBe("A sticker"); + }); + + // Poll start events have both a stable and an unstable event type, each with its own + // registry entry, so exercise both. + it.each([M_POLL_START.name, M_POLL_START.altName])( + "should generate a preview for a poll start event (%s)", + (type) => { + const poll = mkEvent({ + event: true, + type: type!, + user: "@sender:server", + room: room.roomId, + content: { + [M_POLL_START.name]: { + question: { [M_TEXT.name]: "Where shall we eat?" }, + kind: M_POLL_KIND_DISCLOSED.name, + answers: [ + { id: "pizza", [M_TEXT.name]: "Pizza" }, + { id: "sushi", [M_TEXT.name]: "Sushi" }, + ], + }, + }, + }); + + expect(store.generatePreviewForEvent(poll)).toBe("Where shall we eat?"); + }, + ); + + it.each([EventType.CallInvite, EventType.CallAnswer, EventType.CallHangup])( + "should generate a preview for a %s event", + (type) => { + const callEvent = mkEvent({ + event: true, + type, + user: "@sender:server", + room: room.roomId, + content: { call_id: "1" }, + }); + + expect(store.generatePreviewForEvent(callEvent)).toBeTruthy(); + }, + ); + + it("should return an empty string for an event type without a previewer", () => { + const topicEvent = mkEvent({ + event: true, + type: EventType.RoomTopic, + user: "@sender:server", + room: room.roomId, + content: { topic: "A new topic" }, + }); + + expect(store.generatePreviewForEvent(topicEvent)).toBe(""); + }); + }); }); diff --git a/apps/web/test/unit-tests/stores/message-preview/previews/ReactionEventPreview-test.ts b/apps/web/test/unit-tests/stores/message-preview/previews/ReactionEventPreview-test.ts index 977ee61ebe..56e8ddb7f9 100644 --- a/apps/web/test/unit-tests/stores/message-preview/previews/ReactionEventPreview-test.ts +++ b/apps/web/test/unit-tests/stores/message-preview/previews/ReactionEventPreview-test.ts @@ -10,12 +10,12 @@ import { RelationType, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; import { mkEvent, stubClient } from "../../../../test-utils"; -// Import directly from the file to avoid circular dependencies with MessagePreviewStore import { ReactionEventPreview } from "../../../../../src/stores/message-preview/previews/ReactionEventPreview"; +import { MessagePreviewStore } from "../../../../../src/stores/message-preview"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; describe("ReactionEventPreview", () => { - const preview = new ReactionEventPreview(); + const preview = new ReactionEventPreview(MessagePreviewStore.instance); const userId = "@user:example.com"; const roomId = "!room:example.com";