diff --git a/src/stores/room-list/MessagePreviewStore.ts b/src/stores/room-list/MessagePreviewStore.ts index f8af88aed8d..02024948881 100644 --- a/src/stores/room-list/MessagePreviewStore.ts +++ b/src/stores/room-list/MessagePreviewStore.ts @@ -18,6 +18,7 @@ import { Room } from "matrix-js-sdk/src/models/room"; import { isNullOrUndefined } from "matrix-js-sdk/src/utils"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { RelationType } from "matrix-js-sdk/src/matrix"; import { ActionPayload } from "../../dispatcher/payloads"; import { AsyncStoreWithClient } from "../AsyncStoreWithClient"; @@ -102,8 +103,15 @@ export class MessagePreviewStore extends AsyncStoreWithClient { return instance; })(); + /** + * @internal Public for test only + */ + public static testInstance(): MessagePreviewStore { + return new MessagePreviewStore(); + } + // null indicates the preview is empty / irrelevant - private previews = new Map>(); + private previews = new Map>(); private constructor() { super(defaultDispatcher, {}); @@ -131,10 +139,10 @@ export class MessagePreviewStore extends AsyncStoreWithClient { const previews = this.previews.get(room.roomId); if (!previews) return null; - if (!previews.has(inTagId)) { - return previews.get(TAG_ANY)!; + if (previews.has(inTagId)) { + return previews.get(inTagId)![1]; } - return previews.get(inTagId) ?? null; + return previews.get(TAG_ANY)?.[1] ?? null; } public generatePreviewForEvent(event: MatrixEvent): string { @@ -142,16 +150,33 @@ export class MessagePreviewStore extends AsyncStoreWithClient { return previewDef?.previewer.getTextFor(event, undefined, true) ?? ""; } + private shouldSkipPreview(event: MatrixEvent, previousEvent?: MatrixEvent): boolean { + if (event.isRelation(RelationType.Replace)) { + if (previousEvent !== undefined) { + // Ignore edits if they don't apply to the latest event in the room to keep the preview on the latest event + const room = this.matrixClient?.getRoom(event.getRoomId()!); + const relatedEvent = room?.findEventById(event.relationEventId!); + if (relatedEvent !== previousEvent) { + return true; + } + } + } + + return false; + } + private async generatePreview(room: Room, tagId?: TagID): Promise { const events = room.timeline; if (!events) return; // should only happen in tests let map = this.previews.get(room.roomId); if (!map) { - map = new Map(); + map = new Map(); this.previews.set(room.roomId, map); } + const previousEventInAny = map.get(TAG_ANY)?.[0]; + // Set the tags so we know what to generate if (!map.has(TAG_ANY)) map.set(TAG_ANY, null); if (tagId && !map.has(tagId)) map.set(tagId, null); @@ -174,19 +199,24 @@ export class MessagePreviewStore extends AsyncStoreWithClient { const anyPreview = previewDef.previewer.getTextFor(event); if (!anyPreview) continue; // not previewable for some reason - changed = changed || anyPreview !== map.get(TAG_ANY); - map.set(TAG_ANY, anyPreview); + if (!this.shouldSkipPreview(event, previousEventInAny)) { + changed = changed || anyPreview !== map.get(TAG_ANY)?.[1]; + map.set(TAG_ANY, [event, anyPreview]); + } const tagsToGenerate = Array.from(map.keys()).filter((t) => t !== TAG_ANY); // we did the any tag above for (const genTagId of tagsToGenerate) { + const previousEventInTag = map.get(genTagId)?.[0]; + if (this.shouldSkipPreview(event, previousEventInTag)) continue; + const realTagId = genTagId === TAG_ANY ? undefined : genTagId; const preview = previewDef.previewer.getTextFor(event, realTagId); if (preview === anyPreview) { - changed = changed || anyPreview !== map.get(genTagId); + changed = changed || anyPreview !== map.get(genTagId)?.[1]; map.delete(genTagId); } else { - changed = changed || preview !== map.get(genTagId); - map.set(genTagId, preview); + changed = changed || preview !== map.get(genTagId)?.[1]; + map.set(genTagId, preview ? [event, preview] : null); } } @@ -200,7 +230,7 @@ export class MessagePreviewStore extends AsyncStoreWithClient { } // At this point, we didn't generate a preview so clear it - this.previews.set(room.roomId, new Map()); + this.previews.set(room.roomId, new Map()); this.emit(UPDATE_EVENT, this); this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room); } diff --git a/test/stores/room-list/MessagePreviewStore-test.ts b/test/stores/room-list/MessagePreviewStore-test.ts new file mode 100644 index 00000000000..7e886b3e8e2 --- /dev/null +++ b/test/stores/room-list/MessagePreviewStore-test.ts @@ -0,0 +1,205 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { mocked } from "jest-mock"; +import { EventType, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix"; + +import { MessagePreviewStore } from "../../../src/stores/room-list/MessagePreviewStore"; +import { mkEvent, mkMessage, mkStubRoom, setupAsyncStoreWithClient, stubClient } from "../../test-utils"; +import { DefaultTagID } from "../../../src/stores/room-list/models"; + +describe("MessagePreviewStore", () => { + async function addEvent( + store: MessagePreviewStore, + room: Room, + event: MatrixEvent, + fireAction = true, + ): Promise { + room.timeline.push(event); + mocked(room.findEventById).mockImplementation((eventId) => room.timeline.find((e) => e.getId() === eventId)); + if (fireAction) { + // @ts-ignore private access + await store.onAction({ + action: "MatrixActions.Room.timeline", + event, + isLiveEvent: true, + isLiveUnfilteredRoomTimelineEvent: true, + room, + }); + } + } + + it("should ignore edits for events other than the latest one", async () => { + const client = stubClient(); + const room = mkStubRoom("!roomId:server", "Room", client); + mocked(client.getRoom).mockReturnValue(room); + + const store = MessagePreviewStore.testInstance(); + await store.start(); + await setupAsyncStoreWithClient(store, client); + + const firstMessage = mkMessage({ + user: "@sender:server", + event: true, + room: room.roomId, + msg: "First message", + }); + await addEvent(store, room, firstMessage, false); + + await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot( + `"@sender:server: First message"`, + ); + + const secondMessage = mkMessage({ + user: "@sender:server", + event: true, + room: room.roomId, + msg: "Second message", + }); + await addEvent(store, room, secondMessage); + + await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot( + `"@sender:server: Second message"`, + ); + + const firstMessageEdit = mkEvent({ + event: true, + type: EventType.RoomMessage, + user: "@sender:server", + room: room.roomId, + content: { + "body": "* First Message Edit", + "m.new_content": { + body: "First Message Edit", + }, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: firstMessage.getId()!, + }, + }, + }); + await addEvent(store, room, firstMessageEdit); + + await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot( + `"@sender:server: Second message"`, + ); + + const secondMessageEdit = mkEvent({ + event: true, + type: EventType.RoomMessage, + user: "@sender:server", + room: room.roomId, + content: { + "body": "* Second Message Edit", + "m.new_content": { + body: "Second Message Edit", + }, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: secondMessage.getId()!, + }, + }, + }); + await addEvent(store, room, secondMessageEdit); + + await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot( + `"@sender:server: Second Message Edit"`, + ); + }); + + it("should ignore edits to unknown events", async () => { + const client = stubClient(); + const room = mkStubRoom("!roomId:server", "Room", client); + mocked(client.getRoom).mockReturnValue(room); + + const store = MessagePreviewStore.testInstance(); + await store.start(); + await setupAsyncStoreWithClient(store, client); + + await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot(`null`); + + const firstMessage = mkMessage({ + user: "@sender:server", + event: true, + room: room.roomId, + msg: "First message", + }); + await addEvent(store, room, firstMessage, true); + + await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot( + `"@sender:server: First message"`, + ); + + const randomEdit = mkEvent({ + event: true, + type: EventType.RoomMessage, + user: "@sender:server", + room: room.roomId, + content: { + "body": "* Second Message Edit", + "m.new_content": { + body: "Second Message Edit", + }, + "m.relates_to": { + rel_type: RelationType.Replace, + event_id: "!other-event:server", + }, + }, + }); + await addEvent(store, room, randomEdit); + + await expect(store.getPreviewForRoom(room, DefaultTagID.Untagged)).resolves.toMatchInlineSnapshot( + `"@sender:server: First message"`, + ); + }); + + it("should generate correct preview for message events in DMs", async () => { + const client = stubClient(); + const room = mkStubRoom("!roomId:server", "Room", client); + mocked(client.getRoom).mockReturnValue(room); + room.currentState.getJoinedMemberCount = jest.fn().mockReturnValue(2); + + const store = MessagePreviewStore.testInstance(); + await store.start(); + await setupAsyncStoreWithClient(store, client); + + await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot(`null`); + + const firstMessage = mkMessage({ + user: "@sender:server", + event: true, + room: room.roomId, + msg: "First message", + }); + await addEvent(store, room, firstMessage); + + await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot( + `"@sender:server: First message"`, + ); + + const secondMessage = mkMessage({ + user: "@sender:server", + event: true, + room: room.roomId, + msg: "Second message", + }); + await addEvent(store, room, secondMessage); + + await expect(store.getPreviewForRoom(room, DefaultTagID.DM)).resolves.toMatchInlineSnapshot( + `"@sender:server: Second message"`, + ); + }); +});