Ignore edits in message previews when they concern messages other than latest (#10868)
							parent
							
								
									4e5687c454
								
							
						
					
					
						commit
						d53949e863
					
				| 
						 | 
				
			
			@ -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<IState> {
 | 
			
		|||
        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<string, Map<TagID | TAG_ANY, string | null>>();
 | 
			
		||||
    private previews = new Map<string, Map<TagID | TAG_ANY, [MatrixEvent, string] | null>>();
 | 
			
		||||
 | 
			
		||||
    private constructor() {
 | 
			
		||||
        super(defaultDispatcher, {});
 | 
			
		||||
| 
						 | 
				
			
			@ -131,10 +139,10 @@ export class MessagePreviewStore extends AsyncStoreWithClient<IState> {
 | 
			
		|||
        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<IState> {
 | 
			
		|||
        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<void> {
 | 
			
		||||
        const events = room.timeline;
 | 
			
		||||
        if (!events) return; // should only happen in tests
 | 
			
		||||
 | 
			
		||||
        let map = this.previews.get(room.roomId);
 | 
			
		||||
        if (!map) {
 | 
			
		||||
            map = new Map<TagID | TAG_ANY, string | null>();
 | 
			
		||||
            map = new Map<TagID | TAG_ANY, [MatrixEvent, string] | null>();
 | 
			
		||||
            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<IState> {
 | 
			
		|||
            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<IState> {
 | 
			
		|||
        }
 | 
			
		||||
 | 
			
		||||
        // At this point, we didn't generate a preview so clear it
 | 
			
		||||
        this.previews.set(room.roomId, new Map<TagID | TAG_ANY, string | null>());
 | 
			
		||||
        this.previews.set(room.roomId, new Map<TagID | TAG_ANY, [MatrixEvent, string] | null>());
 | 
			
		||||
        this.emit(UPDATE_EVENT, this);
 | 
			
		||||
        this.emit(MessagePreviewStore.getPreviewChangedEventName(room), room);
 | 
			
		||||
    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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<void> {
 | 
			
		||||
        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"`,
 | 
			
		||||
        );
 | 
			
		||||
    });
 | 
			
		||||
});
 | 
			
		||||
		Loading…
	
		Reference in New Issue