Files
element-web/test/unit-tests/components/views/dialogs/ForwardDialog-test.tsx
Zack 7e05552325 Refactor DisambiguatedProfile to shared-components (#31835)
* Refactoring of DisambiguatedProfile into shared components

* correct values and refactoring

* Add username color classes to Storybook and clean up DisambiguatedProfile stories

* Refactor DisambiguatedProfileView to use class component and enhance props structure

* Refactor DisambiguatedProfile components to use member object and enhance props structure

* Update copyright year to 2026 and adjust the tests to fit the correct memberinfro interface

* Add DisambiguatedProfileViewModel class

* Refactor DisambiguatedProfileViewModel to use member object and the rest of the props

* Refactor SenderProfile to use DisambiguatedProfileViewModel and update DisambiguatedProfile styles

* Refactor DisambiguatedProfileView to enhance  interface documentation

* Refactor DisambiguatedProfileView to use CSS modules for styling

* Updated css + tests to fit the new changes

* Update of the test snap to fit the current tests

* Adjusted RoomMemberTitleView and SenderProfile to use the new viewmodel, removed the old component.

* Implemented new viewmodel test for DisambiguatedProfileViewModel

* Update copyright text

* update css class names

* update to correct snapshot after css name changes.

* Apply suggestion from @florianduros

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Moved logic to viewmodel instead of having it in the view. Removed unessecery functions and css.

* removed unessecery file that I copied from root folder, this is no longed needed as I use the root file instead in the viewmodel

* Better Formatting

* Fix issues after merging develop

* FIxed issues with eslint

* Added Visible, non-interactive elements with click handlers must have at least one keyboard listener from eslint docs

* Updated snapshot the fit the latest update with eslint button requirment

* Update snapshot screens for new tests.

* Update tests to reflect snapshots

* Update snapshot due of outdated CSS module classes

* Add useEffect to call setProps on the DisambiguatedProfileViewModel
when props change, ensuring the view updates with the correct display
name. Update LayoutSwitcher snapshot for new CSS classes.

* Fix Playwright editing tests by adding exact match for Edit button selector
The DisambiguatedProfile refactoring added role="button" to the component,
causing the selector { name: "Edit" } to match both the user "Edith" and
the actual Edit button.

* Fix ForwardDialog location tests for async hook rendering The SenderProfile component now uses hooks that trigger async state updates.

* Fix SenderProfile useEffect to only update changeable props

* Added letter spacing

* Added ClassName prop

* Update snapshot

* Update letter-spacing

* Update snapshot screenshots

* Update Snapshots

* Update snapshot

* Removal of letter spacing to test CI

* Apply suggestion from @florianduros

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Added closing brackets + added back letter-spacing

* Update snapshots

* Update snapshot

* Update span to correctly apply to the CI tests, it wasn't possible to use classname as a prop

* Update snapshot

* Added comment to explain the span classNames

* DisambiguatedProfileViewModel.setProps to runtime-changing props

* replace DisambiguatedProfileViewModel setProps with explicit setters and update call sites

* Update Setters

* Prettier FIx

* Update Setters

* update DisambiguatedProfileViewModel setters and tests

* Update SenderProfile to show connect display name

* clone snapshot in setters to trigger reactive updates

* use snapshot.merge in DisambiguatedProfileViewModel setters

* emove duplicated logic in DisambiguatedProfileViewModel

* Change snapshot name

* Update viewmodel

* Updated Tests

* typo

* Update src/viewmodels/profile/DisambiguatedProfileViewModel.ts

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>

* Removal of unused function

* Update snapshots

* Update tests to pass coverage

* Update Eslint

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
2026-02-11 15:31:06 +00:00

457 lines
17 KiB
TypeScript

/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Robin Townsend <robin@robin.town>
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 React from "react";
import {
MatrixEvent,
EventType,
LocationAssetType,
M_ASSET,
M_LOCATION,
M_TIMESTAMP,
M_TEXT,
} from "matrix-js-sdk/src/matrix";
import { act, fireEvent, getByTestId, render, type RenderResult, screen, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { sleep } from "matrix-js-sdk/src/utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import ForwardDialog from "../../../../../src/components/views/dialogs/ForwardDialog";
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
import {
getMockClientWithEventEmitter,
makeBeaconEvent,
makeLegacyLocationEvent,
makeLocationEvent,
mkEvent,
mkMessage,
mkStubRoom,
mockPlatformPeg,
} from "../../../../test-utils";
import { TILE_SERVER_WK_KEY } from "../../../../../src/utils/WellKnownUtils";
import SettingsStore from "../../../../../src/settings/SettingsStore";
// mock offsetParent
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
get() {
return this.parentNode;
},
});
describe("ForwardDialog", () => {
const sourceRoom = "!111111111111111111:example.org";
const aliceId = "@alice:example.org";
const defaultMessage = mkMessage({
room: sourceRoom,
user: aliceId,
msg: "Hello world!",
event: true,
});
const accountDataEvent = new MatrixEvent({
type: EventType.Direct,
sender: aliceId,
content: {},
});
const mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue(aliceId),
getSafeUserId: jest.fn().mockReturnValue(aliceId),
isGuest: jest.fn().mockReturnValue(false),
getVisibleRooms: jest.fn().mockReturnValue([]),
getRoom: jest.fn(),
getAccountData: jest.fn().mockReturnValue(accountDataEvent),
getPushActionsForEvent: jest.fn(),
mxcUrlToHttp: jest.fn().mockReturnValue(""),
getProfileInfo: jest.fn().mockResolvedValue({
displayname: "Alice",
}),
decryptEventIfNeeded: jest.fn(),
sendEvent: jest.fn(),
getClientWellKnown: jest.fn().mockReturnValue({
[TILE_SERVER_WK_KEY.name]: { map_style_url: "maps.com" },
}),
});
const defaultRooms = ["a", "A", "b"].map((name) => mkStubRoom(name, name, mockClient));
const mountForwardDialog = (message = defaultMessage, rooms = defaultRooms, stubSource = false) => {
mockClient.getVisibleRooms.mockReturnValue(rooms);
const sourceRoomStub = mkStubRoom(sourceRoom, sourceRoom, mockClient);
mockClient.getRoom.mockImplementation((roomId) => {
if (stubSource && roomId === sourceRoom) {
return sourceRoomStub; // Return the source room stub, if enabled
}
return rooms.find((room) => room.roomId === roomId) || null;
});
const wrapper: RenderResult = render(
<ForwardDialog
matrixClient={mockClient}
event={message}
permalinkCreator={new RoomPermalinkCreator(undefined!, sourceRoom)}
onFinished={jest.fn()}
/>,
);
return wrapper;
};
beforeEach(() => {
DMRoomMap.makeShared(mockClient);
jest.clearAllMocks();
mockClient.getUserId.mockReturnValue("@bob:example.org");
mockClient.getSafeUserId.mockReturnValue("@bob:example.org");
mockClient.sendEvent.mockReset();
});
afterAll(() => {
jest.spyOn(MatrixClientPeg, "get").mockRestore();
});
it("shows a preview with us as the sender", async () => {
const { container } = mountForwardDialog();
expect(screen.queryByText("Hello world!")).toBeInTheDocument();
// We would just test SenderProfile for the user ID, but it's stubbed
const previewAvatar = container.querySelector(".mx_EventTile_avatar .mx_BaseAvatar");
expect(previewAvatar?.getAttribute("title")).toBe("@bob:example.org");
});
it("filters the rooms", async () => {
const { container } = mountForwardDialog();
expect(container.querySelectorAll(".mx_ForwardList_entry")).toHaveLength(3);
const searchInput = getByTestId(container, "searchbox-input");
await userEvent.type(searchInput, "a");
expect(container.querySelectorAll(".mx_ForwardList_entry")).toHaveLength(2);
});
it("should be navigable using arrow keys", async () => {
const { container } = mountForwardDialog();
const searchBox = getByTestId(container, "searchbox-input");
searchBox.focus();
await waitFor(() =>
expect(container.querySelectorAll(".mx_ForwardList_entry")[0]).toHaveClass("mx_ForwardList_entry_active"),
);
await userEvent.keyboard("[ArrowDown]");
await waitFor(() =>
expect(container.querySelectorAll(".mx_ForwardList_entry")[1]).toHaveClass("mx_ForwardList_entry_active"),
);
await userEvent.keyboard("[ArrowDown]");
await waitFor(() =>
expect(container.querySelectorAll(".mx_ForwardList_entry")[2]).toHaveClass("mx_ForwardList_entry_active"),
);
await userEvent.keyboard("[ArrowUp]");
await waitFor(() =>
expect(container.querySelectorAll(".mx_ForwardList_entry")[1]).toHaveClass("mx_ForwardList_entry_active"),
);
await userEvent.keyboard("[Enter]");
expect(mockClient.sendEvent).toHaveBeenCalledWith("A", "m.room.message", {
"body": "Hello world!",
"msgtype": "m.text",
"m.mentions": {},
});
});
it("tracks message sending progress across multiple rooms", async () => {
mockPlatformPeg();
const { container } = mountForwardDialog();
// Make sendEvent require manual resolution so we can see the sending state
let finishSend: (arg?: any) => void;
let cancelSend: () => void;
mockClient.sendEvent.mockImplementation(
<T extends {}>() =>
new Promise<T>((resolve, reject) => {
finishSend = resolve;
cancelSend = reject;
}),
);
let firstButton!: Element;
let secondButton!: Element;
const update = () => {
[firstButton, secondButton] = container.querySelectorAll(".mx_ForwardList_sendButton");
};
update();
expect(firstButton.className).toContain("mx_ForwardList_canSend");
act(() => {
fireEvent.click(firstButton);
});
update();
expect(firstButton.className).toContain("mx_ForwardList_sending");
await act(async () => {
cancelSend();
// Wait one tick for the button to realize the send failed
await sleep(0);
});
update();
expect(firstButton.className).toContain("mx_ForwardList_sendFailed");
expect(secondButton.className).toContain("mx_ForwardList_canSend");
act(() => {
fireEvent.click(secondButton);
});
update();
expect(secondButton.className).toContain("mx_ForwardList_sending");
await act(async () => {
finishSend();
// Wait one tick for the button to realize the send succeeded
await sleep(0);
});
update();
expect(secondButton.className).toContain("mx_ForwardList_sent");
});
it("can render replies", async () => {
const replyMessage = mkEvent({
type: "m.room.message",
room: "!111111111111111111:example.org",
user: "@alice:example.org",
content: {
"msgtype": "m.text",
"body": "> <@bob:example.org> Hi Alice!\n\nHi Bob!",
"m.relates_to": {
"m.in_reply_to": {
event_id: "$2222222222222222222222222222222222222222222",
},
},
},
event: true,
});
mountForwardDialog(replyMessage);
expect(screen.queryByText("Hi Alice!", { exact: false })).toBeInTheDocument();
});
it("disables buttons for rooms without send permissions", async () => {
const readOnlyRoom = mkStubRoom("a", "a", mockClient);
readOnlyRoom.maySendMessage = jest.fn().mockReturnValue(false);
const rooms = [readOnlyRoom, mkStubRoom("b", "b", mockClient)];
const { container } = mountForwardDialog(undefined, rooms);
const [firstButton, secondButton] = container.querySelectorAll<HTMLButtonElement>(".mx_ForwardList_sendButton");
expect(firstButton.getAttribute("aria-disabled")).toBeTruthy();
expect(secondButton.getAttribute("aria-disabled")).toBeFalsy();
});
describe("Mention recalculation", () => {
const roomId = "a";
const sendClick = (container: HTMLElement): void =>
act(() => {
const sendButton = container.querySelector(".mx_ForwardList_sendButton");
fireEvent.click(sendButton!);
});
const makeMessage = (body: string, mentions: object, formattedBody?: string) => {
return mkEvent({
type: "m.room.message",
room: sourceRoom,
user: "@bob:example.org",
content: {
"msgtype": "m.text",
"body": body,
"m.mentions": mentions,
...(formattedBody && {
format: "org.matrix.custom.html",
formatted_body: formattedBody,
}),
},
event: true,
});
};
it("strips extra mentions", async () => {
const message = makeMessage("Hi Alice", { user_ids: [aliceId] });
const { container } = mountForwardDialog(message);
sendClick(container);
// Expected content should have mentions empty.
expect(mockClient.sendEvent).toHaveBeenCalledWith(roomId, message.getType(), {
...message.getContent(),
"m.mentions": {},
});
});
it("recalculates mention pills", async () => {
const message = makeMessage(
"Hi Alice",
{ user_ids: [aliceId] },
`Hi <a href="https://matrix.to/#/${aliceId}">Alice</a>`,
);
const { container } = mountForwardDialog(message, defaultRooms, true);
sendClick(container);
// Expected content should have mentions empty.
expect(mockClient.sendEvent).toHaveBeenCalledWith(roomId, message.getType(), {
...message.getContent(),
"m.mentions": { user_ids: [aliceId] },
});
});
});
describe("Location events", () => {
// 14.03.2022 16:15
const now = 1647270879403;
const roomId = "a";
const geoUri = "geo:51.5076,-0.1276";
const legacyLocationEvent = makeLegacyLocationEvent(geoUri);
const modernLocationEvent = makeLocationEvent(geoUri);
const pinDropLocationEvent = makeLocationEvent(geoUri, LocationAssetType.Pin);
beforeEach(() => {
// legacy events will default timestamp to Date.now()
// mock a stable now for easy assertion
jest.spyOn(Date, "now").mockReturnValue(now);
});
afterAll(() => {
jest.spyOn(Date, "now").mockRestore();
});
const sendToFirstRoom = (container: HTMLElement): void =>
act(() => {
const sendToFirstRoomButton = container.querySelector(".mx_ForwardList_sendButton");
fireEvent.click(sendToFirstRoomButton!);
});
it("converts legacy location events to pin drop shares", async () => {
const { container } = mountForwardDialog(legacyLocationEvent);
await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy());
sendToFirstRoom(container);
// text and description from original event are removed
// text gets new default message from event values
// timestamp is defaulted to now
const text = `Location ${geoUri} at ${new Date(now).toISOString()}`;
const expectedStrippedContent = {
...modernLocationEvent.getContent(),
body: text,
[M_TEXT.name]: text,
[M_TIMESTAMP.name]: now,
[M_ASSET.name]: { type: LocationAssetType.Pin },
[M_LOCATION.name]: {
uri: geoUri,
},
};
expect(mockClient.sendEvent).toHaveBeenCalledWith(
roomId,
legacyLocationEvent.getType(),
expectedStrippedContent,
);
});
it("removes personal information from static self location shares", async () => {
const { container } = mountForwardDialog(modernLocationEvent);
await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy());
sendToFirstRoom(container);
const timestamp = M_TIMESTAMP.findIn<number>(modernLocationEvent.getContent())!;
// text and description from original event are removed
// text gets new default message from event values
const text = `Location ${geoUri} at ${new Date(timestamp).toISOString()}`;
const expectedStrippedContent = {
...modernLocationEvent.getContent(),
body: text,
[M_TEXT.name]: text,
[M_ASSET.name]: { type: LocationAssetType.Pin },
[M_LOCATION.name]: {
uri: geoUri,
},
};
expect(mockClient.sendEvent).toHaveBeenCalledWith(
roomId,
modernLocationEvent.getType(),
expectedStrippedContent,
);
});
it("forwards beacon location as a pin drop event", async () => {
const timestamp = 123456;
const beaconEvent = makeBeaconEvent("@alice:server.org", { geoUri, timestamp });
const text = `Location ${geoUri} at ${new Date(timestamp).toISOString()}`;
const expectedContent = {
msgtype: "m.location",
body: text,
[M_TEXT.name]: text,
[M_ASSET.name]: { type: LocationAssetType.Pin },
[M_LOCATION.name]: {
uri: geoUri,
},
geo_uri: geoUri,
[M_TIMESTAMP.name]: timestamp,
};
const { container } = mountForwardDialog(beaconEvent);
await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy());
sendToFirstRoom(container);
expect(mockClient.sendEvent).toHaveBeenCalledWith(roomId, EventType.RoomMessage, expectedContent);
});
it("forwards pin drop event", async () => {
const { container } = mountForwardDialog(pinDropLocationEvent);
await waitFor(() => expect(container.querySelector(".mx_MLocationBody")).toBeTruthy());
sendToFirstRoom(container);
const expectedContent = {
...pinDropLocationEvent.getContent(),
"m.mentions": {}, // Add mentions (explicitly set to empty)
};
expect(mockClient.sendEvent).toHaveBeenCalledWith(roomId, pinDropLocationEvent.getType(), expectedContent);
});
});
describe("If the feature_dynamic_room_predecessors is not enabled", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
});
it("Passes through the dynamic predecessor setting", async () => {
mockClient.getVisibleRooms.mockClear();
mountForwardDialog();
expect(mockClient.getVisibleRooms).toHaveBeenCalledWith(false);
});
});
describe("If the feature_dynamic_room_predecessors is enabled", () => {
beforeEach(() => {
// Turn on feature_dynamic_room_predecessors setting
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
it("Passes through the dynamic predecessor setting", async () => {
mockClient.getVisibleRooms.mockClear();
mountForwardDialog();
expect(mockClient.getVisibleRooms).toHaveBeenCalledWith(true);
});
});
});