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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user