Refactor EventTile using the MVVM pattern - #5a (#33587)

* Extract EventTile receipt state

* Extract EventTile thread state

* Extract EventTile reaction relation state

* Extract EventTile reply-chain state

* Keep reply chain collapse wiring in EventTile

* Reuse message event eligibility for receipts
This commit is contained in:
rbondesson
2026-05-22 18:25:32 +00:00
committed by GitHub
parent 6574863fcd
commit 03360eb260
10 changed files with 824 additions and 129 deletions
@@ -0,0 +1,97 @@
/*
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 { EventType, type MatrixEvent, type Relations, MsgType, RelationType } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "../../test-utils";
import {
EVENT_TILE_REACTION_EVENT_TYPE,
EVENT_TILE_REACTION_RELATION_TYPE,
getEventTileReactionRelations,
isEventTileReactionRelation,
type GetRelationsForEvent,
} from "../../../src/viewmodels/room/timeline/event-tile/reactions/EventTileReactionState";
const roomId = "!room:example.org";
const userId = "@alice:example.org";
function makeEvent(): MatrixEvent {
return mkEvent({
event: true,
id: "$event",
type: EventType.RoomMessage,
room: roomId,
user: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
}
describe("EventTileReactionState", () => {
it("gets annotation reaction relations when reactions are enabled", () => {
const relations = {} as Relations;
const getRelationsForEvent: jest.MockedFunction<GetRelationsForEvent> = jest.fn().mockReturnValue(relations);
expect(
getEventTileReactionRelations({
mxEvent: makeEvent(),
showReactions: true,
getRelationsForEvent,
}),
).toBe(relations);
expect(getRelationsForEvent).toHaveBeenCalledWith(
"$event",
EVENT_TILE_REACTION_RELATION_TYPE,
EVENT_TILE_REACTION_EVENT_TYPE,
);
});
it("does not get relations when reactions are disabled", () => {
const getRelationsForEvent: jest.MockedFunction<GetRelationsForEvent> = jest.fn();
expect(
getEventTileReactionRelations({
mxEvent: makeEvent(),
showReactions: false,
getRelationsForEvent,
}),
).toBeNull();
expect(getRelationsForEvent).not.toHaveBeenCalled();
});
it("does not get relations without a relation lookup", () => {
expect(
getEventTileReactionRelations({
mxEvent: makeEvent(),
showReactions: true,
}),
).toBeNull();
});
it("normalizes missing relations to null", () => {
const getRelationsForEvent: jest.MockedFunction<GetRelationsForEvent> = jest.fn().mockReturnValue(undefined);
expect(
getEventTileReactionRelations({
mxEvent: makeEvent(),
showReactions: true,
getRelationsForEvent,
}),
).toBeNull();
});
it("matches reaction relation creation events", () => {
expect(isEventTileReactionRelation(RelationType.Annotation, EventType.Reaction)).toBe(true);
});
it("does not match unrelated relation creation events", () => {
expect(isEventTileReactionRelation(RelationType.Reference, EventType.RoomMessage)).toBe(false);
expect(isEventTileReactionRelation(RelationType.Annotation, EventType.RoomMessage)).toBe(false);
});
});
@@ -0,0 +1,140 @@
/*
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 { EventStatus, EventType, M_POLL_END, M_POLL_START, type MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import {
getEventTileReceiptState,
isEligibleForSpecialReceipt,
type EventTileReceiptStateInput,
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileReceiptState";
const roomId = "!room:example.org";
const ownUserId = "@alice:example.org";
const otherUserId = "@bob:example.org";
function makeEvent({
type = EventType.RoomMessage,
user = ownUserId,
}: {
type?: string;
user?: string;
} = {}): MatrixEvent {
return mkEvent({
event: true,
type,
room: roomId,
user,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
}
function makeInput(overrides: Partial<EventTileReceiptStateInput> = {}): EventTileReceiptStateInput {
return {
mxEvent: makeEvent(),
hasRoom: true,
ownUserId,
lastSuccessful: true,
timelineRenderingType: TimelineRenderingType.Room,
...overrides,
};
}
describe("EventTileReceiptState", () => {
it.each([
EventType.RoomMessage,
EventType.RoomMessageEncrypted,
EventType.Sticker,
M_POLL_START.name,
M_POLL_END.name,
])("treats %s as eligible for special receipts", (type) => {
expect(isEligibleForSpecialReceipt(makeEvent({ type }))).toBe(true);
});
it("does not treat state events as eligible for special receipts", () => {
expect(isEligibleForSpecialReceipt(makeEvent({ type: EventType.RoomName }))).toBe(false);
});
it("shows a sent receipt for the last successful own event", () => {
const state = getEventTileReceiptState(makeInput());
expect(state).toMatchObject({
isEligibleForSpecialReceipt: true,
shouldShowSentReceipt: true,
shouldShowSendingReceipt: false,
shouldListenForReceipts: true,
});
});
it("shows a sent receipt for an explicitly sent event", () => {
const state = getEventTileReceiptState(makeInput({ eventSendStatus: EventStatus.SENT }));
expect(state.shouldShowSentReceipt).toBe(true);
expect(state.shouldListenForReceipts).toBe(true);
});
it("shows a sending receipt for pending send states", () => {
for (const eventSendStatus of [EventStatus.QUEUED, EventStatus.SENDING, EventStatus.ENCRYPTING]) {
const state = getEventTileReceiptState(makeInput({ eventSendStatus }));
expect(state.shouldShowSentReceipt).toBe(false);
expect(state.shouldShowSendingReceipt).toBe(true);
expect(state.shouldListenForReceipts).toBe(true);
}
});
it("does not show a sent receipt in the thread list", () => {
const state = getEventTileReceiptState(
makeInput({
timelineRenderingType: TimelineRenderingType.ThreadsList,
}),
);
expect(state.shouldShowSentReceipt).toBe(false);
expect(state.shouldListenForReceipts).toBe(false);
});
it("does not show special receipts once read receipts are present", () => {
const state = getEventTileReceiptState(
makeInput({
readReceipts: [
{
userId: otherUserId,
},
],
}),
);
expect(state.isEligibleForSpecialReceipt).toBe(false);
expect(state.shouldShowSentReceipt).toBe(false);
expect(state.shouldShowSendingReceipt).toBe(false);
expect(state.shouldListenForReceipts).toBe(false);
});
it("does not show special receipts for another sender", () => {
const state = getEventTileReceiptState(
makeInput({
mxEvent: makeEvent({ user: otherUserId }),
}),
);
expect(state.isEligibleForSpecialReceipt).toBe(false);
expect(state.shouldListenForReceipts).toBe(false);
});
it("does not show special receipts when the room is not available", () => {
const state = getEventTileReceiptState(makeInput({ hasRoom: false }));
expect(state.isEligibleForSpecialReceipt).toBe(false);
expect(state.shouldListenForReceipts).toBe(false);
});
});
@@ -0,0 +1,79 @@
/*
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 MatrixEvent } from "matrix-js-sdk/src/matrix";
import { mkMessage } from "../../test-utils";
import { getEventTileReplyChainState } from "../../../src/viewmodels/room/timeline/event-tile/EventTileReplyChainState";
const roomId = "!room:example.org";
function makeMessage(): MatrixEvent {
return mkMessage({
room: roomId,
user: "@alice:example.org",
msg: "Message",
event: true,
});
}
function makeReply(): MatrixEvent {
const parentEvent = makeMessage();
return mkMessage({
room: roomId,
user: "@bob:example.org",
msg: "Reply",
event: true,
relatesTo: {
"m.in_reply_to": {
event_id: parentEvent.getId(),
},
},
});
}
describe("EventTileReplyChainState", () => {
it("does not show a reply chain when the event has no renderer", () => {
const state = getEventTileReplyChainState({
mxEvent: makeReply(),
hasRenderer: false,
});
expect(state.shouldShowReplyChain).toBe(false);
});
it("does not show a reply chain for non-reply events", () => {
const state = getEventTileReplyChainState({
mxEvent: makeMessage(),
hasRenderer: true,
});
expect(state.shouldShowReplyChain).toBe(false);
});
it("shows a reply chain for reply events with a renderer", () => {
const state = getEventTileReplyChainState({
mxEvent: makeReply(),
hasRenderer: true,
});
expect(state.shouldShowReplyChain).toBe(true);
});
it("does not show a reply chain for redacted reply events", () => {
const replyEvent = makeReply();
jest.spyOn(replyEvent, "isRedacted").mockReturnValue(true);
const state = getEventTileReplyChainState({
mxEvent: replyEvent,
hasRenderer: true,
});
expect(state.shouldShowReplyChain).toBe(false);
});
});
@@ -0,0 +1,164 @@
/*
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 { EventType, type MatrixEvent, MsgType, type Thread } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "../../test-utils";
import { TimelineRenderingType } from "../../../src/contexts/RoomContext";
import {
getEventTileThread,
getEventTileThreadState,
type EventTileThreadLookup,
type EventTileThreadStateInput,
} from "../../../src/viewmodels/room/timeline/event-tile/EventTileThreadState";
const roomId = "!room:example.org";
const userId = "@alice:example.org";
function makeEvent({ id = "$event" }: { id?: string } = {}): MatrixEvent {
return mkEvent({
event: true,
id,
type: EventType.RoomMessage,
room: roomId,
user: userId,
content: {
msgtype: MsgType.Text,
body: "Hello",
},
});
}
function makeThread({
id = "$event",
replyToEvent,
}: {
id?: string;
replyToEvent?: MatrixEvent;
} = {}): Thread {
return {
id,
length: 2,
replyToEvent,
} as Thread;
}
function makeInput(overrides: Partial<EventTileThreadStateInput> = {}): EventTileThreadStateInput {
return {
mxEvent: makeEvent(),
thread: null,
timelineRenderingType: TimelineRenderingType.Room,
...overrides,
};
}
function setThreadRootId(mxEvent: MatrixEvent, threadRootId: string): void {
Object.defineProperty(mxEvent, "threadRootId", {
configurable: true,
value: threadRootId,
});
}
describe("EventTileThreadState", () => {
it("uses the event thread when it is already available", () => {
const mxEvent = makeEvent();
const thread = makeThread();
jest.spyOn(mxEvent, "getThread").mockReturnValue(thread);
const room: EventTileThreadLookup = {
findThreadForEvent: jest.fn(),
};
expect(getEventTileThread(mxEvent, room)).toBe(thread);
expect(room.findThreadForEvent).not.toHaveBeenCalled();
});
it("falls back to the room thread lookup", () => {
const mxEvent = makeEvent();
const thread = makeThread();
jest.spyOn(mxEvent, "getThread").mockReturnValue(undefined);
const room: EventTileThreadLookup = {
findThreadForEvent: jest.fn().mockReturnValue(thread),
};
expect(getEventTileThread(mxEvent, room)).toBe(thread);
expect(room.findThreadForEvent).toHaveBeenCalledWith(mxEvent);
});
it("returns null when no thread can be found", () => {
const mxEvent = makeEvent();
jest.spyOn(mxEvent, "getThread").mockReturnValue(undefined);
expect(getEventTileThread(mxEvent, null)).toBeNull();
});
it("shows the thread summary for thread root events", () => {
const mxEvent = makeEvent({ id: "$thread-root" });
const thread = makeThread({ id: "$thread-root" });
const state = getEventTileThreadState(makeInput({ mxEvent, thread }));
expect(state.shouldShowThreadSummary).toBe(true);
expect(state.shouldShowThreadPanelSummary).toBe(true);
expect(state.searchThreadInfo.kind).toBe("none");
});
it("derives the thread panel timestamp from the latest reply", () => {
const replyToEvent = makeEvent({ id: "$reply" });
jest.spyOn(replyToEvent, "getTs").mockReturnValue(123);
const state = getEventTileThreadState(
makeInput({
thread: makeThread({ replyToEvent }),
}),
);
expect(state.threadReplyEventTs).toBe(123);
});
it("shows linked search thread info for thread replies with a highlight link", () => {
const mxEvent = makeEvent();
setThreadRootId(mxEvent, "$thread-root");
const state = getEventTileThreadState(
makeInput({
mxEvent,
timelineRenderingType: TimelineRenderingType.Search,
highlightLink: "https://example.org/thread",
}),
);
expect(state.searchThreadInfo).toEqual({
kind: "link",
href: "https://example.org/thread",
});
});
it("shows text search thread info for thread replies without a highlight link", () => {
const mxEvent = makeEvent();
setThreadRootId(mxEvent, "$thread-root");
const state = getEventTileThreadState(
makeInput({
mxEvent,
timelineRenderingType: TimelineRenderingType.Search,
}),
);
expect(state.searchThreadInfo).toEqual({
kind: "text",
});
});
it("does not show search thread info outside search timelines", () => {
const mxEvent = makeEvent();
setThreadRootId(mxEvent, "$thread-root");
const state = getEventTileThreadState(makeInput({ mxEvent }));
expect(state.searchThreadInfo.kind).toBe("none");
});
});