From a1c1d330e9571915a92e24d6051ac273406bb20c Mon Sep 17 00:00:00 2001 From: rbondesson Date: Fri, 8 May 2026 11:33:23 +0200 Subject: [PATCH] Fix msgtype changes from edits not rerendering timeline tiles (#33416) * Fix msgtype changes from edits not rerendering timeline tiles * Change approach to view model listeners instead * Repaint the mounted tile on MatrixEventEvent.Replaced --- .../src/components/views/rooms/EventTile.tsx | 3 ++ .../message-body/EventContentBodyViewModel.ts | 32 ++++++++++++++- .../event-tile/body/TextualBodyViewModel.tsx | 26 +++++++++++- .../views/messages/TextualBody-test.tsx | 40 ++++++++++++++++++- .../components/views/rooms/EventTile-test.tsx | 29 ++++++++++++++ 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 73b085e2c6..0590c06887 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -620,6 +620,7 @@ export class UnwrappedEventTile extends React.Component /** called when the event is edited after we show it. */ private readonly onReplaced = (): void => { + this.forceUpdate(); // re-verify the event if it is replaced (the edit may not be verified) this.verifyEvent(); }; @@ -995,6 +996,7 @@ export class UnwrappedEventTile extends React.Component public render(): ReactNode { const msgtype = this.props.mxEvent.getContent().msgtype; const eventType = this.props.mxEvent.getType(); + const replacingEventId = this.props.mxEvent.replacingEventId(); const { hasRenderer, @@ -1335,6 +1337,7 @@ export class UnwrappedEventTile extends React.Component // overrides ref: this.tile, + replacingEventId, isSeeingThroughMessageHiddenForModeration, // appease TS diff --git a/apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts b/apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts index cfa49544b9..2d261400fd 100644 --- a/apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts +++ b/apps/web/src/viewmodels/message-body/EventContentBodyViewModel.ts @@ -5,7 +5,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type IContent, type MatrixClient, type MatrixEvent, MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix"; +import { + type IContent, + type MatrixClient, + type MatrixEvent, + MatrixEventEvent, + MsgType, + PushRuleKind, +} from "matrix-js-sdk/src/matrix"; import parse from "html-react-parser"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { @@ -169,6 +176,8 @@ export class EventContentBodyViewModel extends BaseViewModel implements EventContentBodyViewModelInterface { + private watchedEvent?: MatrixEvent; + private static readonly computeBodySnapshot = ( props: EventContentBodyViewModelProps, ): Pick => { @@ -226,6 +235,8 @@ export class EventContentBodyViewModel }; super(propsWithSettingDefaults, EventContentBodyViewModel.computeSnapshot(propsWithSettingDefaults)); + this.watchEvent(propsWithSettingDefaults.mxEvent); + this.disposables.track(this.unwatchEvent); const enableBigEmojiWatcherRef = SettingsStore.watchSetting( "TextualBody.enableBigEmoji", @@ -246,7 +257,26 @@ export class EventContentBodyViewModel this.disposables.track(() => SettingsStore.unwatchSetting(shouldShowPillAvatarWatcherRef)); } + private readonly onEventContentChanged = (): void => { + const mxEvent = this.props.mxEvent; + if (!mxEvent) return; + this.setEventContent(mxEvent, mxEvent.getContent()); + }; + + private watchEvent(mxEvent?: MatrixEvent): void { + if (this.watchedEvent === mxEvent) return; + this.unwatchEvent(); + this.watchedEvent = mxEvent; + this.watchedEvent?.on(MatrixEventEvent.Replaced, this.onEventContentChanged); + } + + private readonly unwatchEvent = (): void => { + this.watchedEvent?.off(MatrixEventEvent.Replaced, this.onEventContentChanged); + this.watchedEvent = undefined; + }; + public setEventContent = (mxEvent: MatrixEvent | undefined, content: IContent): void => { + this.watchEvent(mxEvent); this.props.mxEvent = mxEvent; this.props.content = content; const { body, formattedBody, className } = EventContentBodyViewModel.computeBodySnapshot(this.props); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx index e6ab537f33..b0d5b9baa5 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx +++ b/apps/web/src/viewmodels/room/timeline/event-tile/body/TextualBodyViewModel.tsx @@ -6,7 +6,7 @@ */ import React, { type MouseEvent } from "react"; -import { MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixEventEvent, MsgType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { BaseViewModel, LINKIFIED_DATA_ATTRIBUTE, @@ -60,6 +60,8 @@ export class TextualBodyViewModel extends BaseViewModel implements TextualBodyViewModelInterface { + private watchedEvent?: MatrixEvent; + private static readonly getKind = (mxEvent: MatrixEvent): TextualBodyViewKind => { const msgtype = mxEvent.getContent().msgtype as MsgType | undefined; @@ -120,7 +122,8 @@ export class TextualBodyViewModel | "editedMarkerTooltip" | "editedMarkerCaption" > => { - if (!props.replacingEventId) { + const replacingEventId = props.replacingEventId ?? props.mxEvent.replacingEventId(); + if (!replacingEventId) { return { showEditedMarker: false, editedMarkerText: undefined, @@ -197,8 +200,26 @@ export class TextualBodyViewModel public constructor(props: TextualBodyViewModelProps) { super(props, TextualBodyViewModel.computeSnapshot(props)); + this.watchEvent(props.mxEvent); + this.disposables.track(this.unwatchEvent); } + private readonly onEventContentChanged = (): void => { + this.snapshot.merge(TextualBodyViewModel.computeEventSnapshot(this.props)); + }; + + private watchEvent(mxEvent: MatrixEvent): void { + if (this.watchedEvent === mxEvent) return; + this.unwatchEvent(); + this.watchedEvent = mxEvent; + this.watchedEvent.on(MatrixEventEvent.Replaced, this.onEventContentChanged); + } + + private readonly unwatchEvent = (): void => { + this.watchedEvent?.off(MatrixEventEvent.Replaced, this.onEventContentChanged); + this.watchedEvent = undefined; + }; + public setId(id: string | undefined): void { this.props = { ...this.props, @@ -213,6 +234,7 @@ export class TextualBodyViewModel ...this.props, mxEvent, }; + this.watchEvent(mxEvent); this.snapshot.merge(TextualBodyViewModel.computeEventSnapshot(this.props)); } diff --git a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx index c229eebee8..60fd55c655 100644 --- a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React, { type ComponentProps } from "react"; import { type MatrixClient, type MatrixEvent, PushRuleKind, type Room } from "matrix-js-sdk/src/matrix"; import { mocked, type MockedObject } from "jest-mock"; -import { render, waitFor } from "jest-matrix-react"; +import { act, render, waitFor } from "jest-matrix-react"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { @@ -174,6 +174,44 @@ describe("", () => { expect(container).toHaveTextContent("* sender winks(edited)"); }); + it("updates the body kind when an edit changes msgtype to m.emote", async () => { + DMRoomMap.makeShared(defaultMatrixClient); + + const ev = mkEvent({ + type: "m.room.message", + room: room1Id, + user: "sender", + content: { + body: "hello", + msgtype: "m.text", + }, + event: true, + }); + + const { container } = getComponent({ mxEvent: ev }); + expect(container).toHaveTextContent("hello"); + + const edit = mkEvent({ + type: "m.room.message", + room: room1Id, + user: "sender", + content: { + "body": "* waves", + "msgtype": "m.emote", + "m.new_content": { + body: "waves", + msgtype: "m.emote", + }, + }, + event: true, + }); + act(() => { + ev.makeReplaced(edit); + }); + + await waitFor(() => expect(container).toHaveTextContent("* sender waves(edited)")); + }); + it("renders m.notice correctly", () => { DMRoomMap.makeShared(defaultMatrixClient); diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index 42e43771de..1d2d0ac657 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -214,6 +214,35 @@ describe("EventTile", () => { expect(screen.getByText("Can't load this message (m.room.message)")).toBeInTheDocument(); }); }); + + it("updates msgtype-derived tile classes when an edit changes msgtype to m.emote", async () => { + const { container } = getComponent(); + expect(container.querySelector(".mx_EventTile_emote")).toBeNull(); + + const edit = new MatrixEvent({ + type: EventType.RoomMessage, + room_id: ROOM_ID, + sender: "@alice:example.org", + content: { + "body": "* waves", + "msgtype": "m.emote", + "m.new_content": { + body: "waves", + msgtype: "m.emote", + }, + "m.relates_to": { + rel_type: "m.replace", + event_id: mxEvent.getId(), + }, + }, + }); + + act(() => { + mxEvent.makeReplaced(edit); + }); + + await waitFor(() => expect(container.querySelector(".mx_EventTile_emote")).not.toBeNull()); + }); }); describe("EventTile in the right panel", () => {