Refactor E2eMessageSharedIcon to MVVM (#33544)
* Refactor E2eMessageSharedIcon to MVVM * FIx Prettier and i18n * Fix prettier * Remove legacy E2eMessageSharedIcon component * Cover E2eMessageSharedIcon MVVM updates * Fix prettier * Update apps/web/src/components/views/rooms/EventTile.tsx Co-authored-by: Florian Duros <florian.duros@ormaz.fr> * Skip unknown E2E shared icon visual * Track E2E shared icon listener disposal * Remove E2E shared icon room state guard * Remove E2E shared icon client resync * Use built-in disposables for shared icon VM * Update shared icon VM without React key --------- Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
This commit is contained in:
-59
@@ -1,59 +0,0 @@
|
||||
/*
|
||||
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 { render, waitFor } from "jest-matrix-react";
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { type RoomMember, type RoomState } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { E2eMessageSharedIcon } from "../../../../../../src/components/views/rooms/EventTile/E2eMessageSharedIcon.tsx";
|
||||
import { createTestClient, mkStubRoom, withClientContextRenderOptions } from "../../../../../test-utils";
|
||||
|
||||
describe("E2eMessageSharedIcon", () => {
|
||||
it("renders correctly for a known user", async () => {
|
||||
const mockClient = createTestClient();
|
||||
const mockMember = { rawDisplayName: "Bob" } as RoomMember;
|
||||
const mockState = {
|
||||
getMember: (userId) => {
|
||||
expect(userId).toEqual("@bob:example.com");
|
||||
return mockMember;
|
||||
},
|
||||
} as RoomState;
|
||||
const mockRoom = mkStubRoom("!roomId", undefined, mockClient, mockState);
|
||||
mocked(mockClient.getRoom).mockImplementation((roomId) => {
|
||||
expect(roomId).toEqual("!roomId");
|
||||
return mockRoom;
|
||||
});
|
||||
|
||||
const result = render(
|
||||
<E2eMessageSharedIcon keyForwardingUserId="@bob:example.com" roomId="!roomId" />,
|
||||
withClientContextRenderOptions(mockClient),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.container.firstChild).toHaveAccessibleName(
|
||||
"Bob (@bob:example.com) shared this message since you were not in the room when it was sent.",
|
||||
),
|
||||
);
|
||||
expect(result.container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("renders correctly for an unknown user", async () => {
|
||||
const mockClient = createTestClient();
|
||||
const result = render(
|
||||
<E2eMessageSharedIcon keyForwardingUserId="@bob:example.com" roomId="!roomId" />,
|
||||
withClientContextRenderOptions(mockClient),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.container.firstChild).toHaveAccessibleName(
|
||||
"@bob:example.com (@bob:example.com) shared this message since you were not in the room when it was sent.",
|
||||
),
|
||||
);
|
||||
expect(result.container).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`E2eMessageSharedIcon renders correctly for a known user 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="State of the end-to-end encryption"
|
||||
aria-labelledby="react-use-id-1"
|
||||
class="_e2ePadlock_1hynz_8 _normal_1hynz_21 mx_EventTile_e2eIcon"
|
||||
data-testid="e2e-padlock"
|
||||
role="img"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.288 7.288A.97.97 0 0 1 12 7q.424 0 .713.287Q13 7.576 13 8t-.287.713A.97.97 0 0 1 12 9a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 8q0-.424.287-.713m.001 4.001A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713v4q0 .424-.287.712A.97.97 0 0 1 12 17a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 16v-4q0-.424.287-.713"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 1-16 0 8 8 0 0 1 16 0"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`E2eMessageSharedIcon renders correctly for an unknown user 1`] = `
|
||||
<div>
|
||||
<div
|
||||
aria-label="State of the end-to-end encryption"
|
||||
aria-labelledby="react-use-id-1"
|
||||
class="_e2ePadlock_1hynz_8 _normal_1hynz_21 mx_EventTile_e2eIcon"
|
||||
data-testid="e2e-padlock"
|
||||
role="img"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.288 7.288A.97.97 0 0 1 12 7q.424 0 .713.287Q13 7.576 13 8t-.287.713A.97.97 0 0 1 12 9a.97.97 0 0 1-.713-.287A.97.97 0 0 1 11 8q0-.424.287-.713m.001 4.001A.97.97 0 0 1 12 11q.424 0 .713.287.287.288.287.713v4q0 .424-.287.712A.97.97 0 0 1 12 17a.97.97 0 0 1-.713-.288A.97.97 0 0 1 11 16v-4q0-.424.287-.713"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M22 12c0 5.523-4.477 10-10 10S2 17.523 2 12 6.477 2 12 2s10 4.477 10 10m-2 0a8 8 0 1 1-16 0 8 8 0 0 1 16 0"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -0,0 +1,209 @@
|
||||
/*
|
||||
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 { EventEmitter } from "events";
|
||||
import {
|
||||
EventTimeline,
|
||||
RoomStateEvent,
|
||||
type MatrixClient,
|
||||
type Room,
|
||||
type RoomMember,
|
||||
type RoomState,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { E2eMessageSharedIconViewModel } from "../../../src/viewmodels/room/timeline/event-tile/E2eMessageSharedIconViewModel";
|
||||
|
||||
type TestRoomState = EventEmitter & {
|
||||
getMember: jest.Mock<RoomMember | null, [userId: string]>;
|
||||
};
|
||||
|
||||
const USER_ID = "@bob:example.com";
|
||||
const OTHER_USER_ID = "@alice:example.com";
|
||||
const ROOM_ID = "!roomId";
|
||||
const OTHER_ROOM_ID = "!otherRoomId";
|
||||
|
||||
function createGetMember(
|
||||
displayNames: Record<string, string | undefined>,
|
||||
): jest.Mock<RoomMember | null, [userId: string]> {
|
||||
const getMember = jest.fn((userId: string): RoomMember | null => {
|
||||
const displayName = displayNames[userId];
|
||||
if (displayName === undefined) return null;
|
||||
|
||||
return { rawDisplayName: displayName } as RoomMember;
|
||||
});
|
||||
|
||||
return getMember;
|
||||
}
|
||||
|
||||
function createRoomState(displayNames: Record<string, string | undefined> = {}): TestRoomState {
|
||||
const getMember = createGetMember(displayNames);
|
||||
|
||||
return Object.assign(new EventEmitter(), { getMember }) as TestRoomState;
|
||||
}
|
||||
|
||||
function createRoom(roomState: RoomState): Room {
|
||||
const room = {
|
||||
getLiveTimeline: jest.fn(() => ({
|
||||
getState: jest.fn((timeline: EventTimeline) => {
|
||||
expect(timeline).toBe(EventTimeline.FORWARDS);
|
||||
return roomState;
|
||||
}),
|
||||
})),
|
||||
} as unknown as Room;
|
||||
|
||||
return room;
|
||||
}
|
||||
|
||||
function createClient(roomStates: Record<string, RoomState | undefined>): MatrixClient {
|
||||
return {
|
||||
getRoom: jest.fn((roomId: string) => {
|
||||
const roomState = roomStates[roomId];
|
||||
|
||||
return roomState ? createRoom(roomState) : null;
|
||||
}),
|
||||
} as unknown as MatrixClient;
|
||||
}
|
||||
|
||||
function createViewModel(displayName?: string): {
|
||||
roomState: TestRoomState;
|
||||
vm: E2eMessageSharedIconViewModel;
|
||||
} {
|
||||
const roomState = createRoomState({ [USER_ID]: displayName });
|
||||
const client = createClient({ [ROOM_ID]: roomState as unknown as RoomState });
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
|
||||
return { roomState, vm };
|
||||
}
|
||||
|
||||
describe("E2eMessageSharedIconViewModel", () => {
|
||||
it("builds a snapshot with the forwarding member display name", () => {
|
||||
const { vm } = createViewModel("Bob");
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: "Bob",
|
||||
userId: USER_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the user ID when the forwarding member is unknown", () => {
|
||||
const { vm } = createViewModel();
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: USER_ID,
|
||||
userId: USER_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the user ID when the room is unavailable", () => {
|
||||
const client = createClient({});
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: USER_ID,
|
||||
userId: USER_ID,
|
||||
});
|
||||
expect(() => vm.dispose()).not.toThrow();
|
||||
});
|
||||
|
||||
it("updates when room state events change the forwarding member display name", () => {
|
||||
const { roomState, vm } = createViewModel("Bob");
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
roomState.getMember.mockReturnValue({ rawDisplayName: "Alice" } as RoomMember);
|
||||
roomState.emit(RoomStateEvent.Events);
|
||||
|
||||
expect(vm.getSnapshot().displayName).toBe("Alice");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates when the key forwarding user changes", () => {
|
||||
const roomState = createRoomState({
|
||||
[USER_ID]: "Bob",
|
||||
[OTHER_USER_ID]: "Alice",
|
||||
});
|
||||
const client = createClient({ [ROOM_ID]: roomState as unknown as RoomState });
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setKeyForwardingUserId(OTHER_USER_ID);
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: "Alice",
|
||||
userId: OTHER_USER_ID,
|
||||
});
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates and rebinds its room state listener when the room changes", () => {
|
||||
const originalRoomState = createRoomState({ [USER_ID]: "Bob" });
|
||||
const newRoomState = createRoomState({ [USER_ID]: "Alice" });
|
||||
const client = createClient({
|
||||
[ROOM_ID]: originalRoomState as unknown as RoomState,
|
||||
[OTHER_ROOM_ID]: newRoomState as unknown as RoomState,
|
||||
});
|
||||
const vm = new E2eMessageSharedIconViewModel({
|
||||
client,
|
||||
roomId: ROOM_ID,
|
||||
keyForwardingUserId: USER_ID,
|
||||
});
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setRoomId(OTHER_ROOM_ID);
|
||||
|
||||
expect(originalRoomState.listenerCount(RoomStateEvent.Events)).toBe(0);
|
||||
expect(newRoomState.listenerCount(RoomStateEvent.Events)).toBe(1);
|
||||
expect(vm.getSnapshot().displayName).toBe("Alice");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
originalRoomState.getMember.mockReturnValue({ rawDisplayName: "Ignored" } as RoomMember);
|
||||
originalRoomState.emit(RoomStateEvent.Events);
|
||||
|
||||
expect(vm.getSnapshot().displayName).toBe("Alice");
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
newRoomState.getMember.mockReturnValue({ rawDisplayName: "Carol" } as RoomMember);
|
||||
newRoomState.emit(RoomStateEvent.Events);
|
||||
|
||||
expect(vm.getSnapshot().displayName).toBe("Carol");
|
||||
expect(listener).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not emit updates when setters receive unchanged values", () => {
|
||||
const { vm } = createViewModel("Bob");
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setRoomId(ROOM_ID);
|
||||
vm.setKeyForwardingUserId(USER_ID);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("removes its room state listener when disposed", () => {
|
||||
const { roomState, vm } = createViewModel("Bob");
|
||||
expect(roomState.listenerCount(RoomStateEvent.Events)).toBe(1);
|
||||
|
||||
vm.dispose();
|
||||
|
||||
expect(roomState.listenerCount(RoomStateEvent.Events)).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user