* Improve coverage * Add view model unit tests to improve coverage * Extract initial pure EventTile derived state helpers * Extract EventTile derived state helpers * Extract EventTile class state derivation * Extract EventTile line class derivation * Extract EventTile sender profile state * Extract EventTile avatar member selection * Extract EventTile avatar clickability state * Extract EventTile sender profile mode * Extract EventTile action bar visibility * Extract EventTile timestamp visibility * Extract EventTile timestamp selection * Extract EventTile timestamp display state * Extract ReplyChain timestamp visibility * Extract EventTile footer display state * Fix sonar issue
This commit is contained in:
@@ -19,6 +19,8 @@ import {
|
||||
MsgType,
|
||||
NotificationCountType,
|
||||
PendingEventOrdering,
|
||||
RelationType,
|
||||
type Relations,
|
||||
Room,
|
||||
TweakName,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
@@ -101,6 +103,41 @@ function makeThreadReplyEvent(roomId: string): MatrixEvent {
|
||||
});
|
||||
}
|
||||
|
||||
function makeReactionEvent(roomId: string, targetEventId: string, sender: string, key: string): MatrixEvent {
|
||||
return mkEvent({
|
||||
event: true,
|
||||
type: EventType.Reaction,
|
||||
room: roomId,
|
||||
user: sender,
|
||||
content: {
|
||||
"m.relates_to": {
|
||||
rel_type: RelationType.Annotation,
|
||||
event_id: targetEventId,
|
||||
key,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeRelations(
|
||||
reactionsByKey: Map<string, MatrixEvent[]>,
|
||||
reactionsBySender: Record<string, MatrixEvent[]> = {},
|
||||
): Relations {
|
||||
return {
|
||||
getSortedAnnotationsByKey: () =>
|
||||
[...reactionsByKey.entries()].map(([key, events]) => [key, new Set(events)] as [string, Set<MatrixEvent>]),
|
||||
getAnnotationsBySender: () =>
|
||||
Object.fromEntries(
|
||||
Object.entries(reactionsBySender).map(([sender, events]) => [
|
||||
sender,
|
||||
new Map(events.map((ev) => [ev.getId(), ev])),
|
||||
]),
|
||||
),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
} as unknown as Relations;
|
||||
}
|
||||
|
||||
describe("EventTile", () => {
|
||||
const ROOM_ID = "!roomId:example.org";
|
||||
let mxEvent: MatrixEvent;
|
||||
@@ -259,6 +296,18 @@ describe("EventTile", () => {
|
||||
|
||||
expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens");
|
||||
});
|
||||
|
||||
it("sets aria-live to off when the send status is undefined", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("aria-live", "off");
|
||||
});
|
||||
|
||||
it("does not set aria-live when the send status is explicitly null", () => {
|
||||
const { container } = getComponent({ eventSendStatus: null as unknown as EventStatus });
|
||||
|
||||
expect(getTile(container)).not.toHaveAttribute("aria-live");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering root attributes", () => {
|
||||
@@ -507,6 +556,68 @@ describe("EventTile", () => {
|
||||
expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile"));
|
||||
expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar"));
|
||||
});
|
||||
|
||||
it("keeps sender and avatar when only the layout prop is set to bubble", () => {
|
||||
const { container } = getComponent({ layout: Layout.Bubble });
|
||||
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the sender but keeps the info-message avatar for room create events", () => {
|
||||
const createEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomCreate,
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: { creator: "@alice:example.org", room_version: "1" },
|
||||
});
|
||||
const { container } = getComponent({ mxEvent: createEvent }, TimelineRenderingType.Room, {
|
||||
showHiddenEvents: true,
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull();
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the notification avatar independently from the sender details", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.Notification);
|
||||
const details = container.querySelector<HTMLElement>(".mx_EventTile_details");
|
||||
const avatar = container.querySelector<HTMLElement>(".mx_EventTile_avatar");
|
||||
|
||||
expect(details).not.toBeNull();
|
||||
expect(avatar).not.toBeNull();
|
||||
expect(details).not.toContainElement(avatar);
|
||||
});
|
||||
});
|
||||
|
||||
describe("continuation rendering", () => {
|
||||
it.each([TimelineRenderingType.Room, TimelineRenderingType.Search, TimelineRenderingType.Thread])(
|
||||
"keeps continuation styling in %s timelines",
|
||||
(renderingType) => {
|
||||
const { container } = getComponent({ continuation: true }, renderingType);
|
||||
|
||||
expect(getTile(container)).toHaveClass("mx_EventTile_continuation");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
|
||||
"drops continuation styling in %s timelines when not using bubble layout",
|
||||
(renderingType) => {
|
||||
const { container } = getComponent({ continuation: true }, renderingType);
|
||||
|
||||
expect(getTile(container)).not.toHaveClass("mx_EventTile_continuation");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([TimelineRenderingType.File, TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
|
||||
"keeps continuation styling in %s timelines when using bubble layout",
|
||||
(renderingType) => {
|
||||
const { container } = getComponent({ continuation: true, layout: Layout.Bubble }, renderingType);
|
||||
|
||||
expect(getTile(container)).toHaveClass("mx_EventTile_continuation");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("read receipt option", () => {
|
||||
@@ -614,6 +725,55 @@ describe("EventTile", () => {
|
||||
expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull();
|
||||
expect(screen.getByText("Pinned message")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the IRC footer inside the event line", () => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
const { container } = getComponent({ layout: Layout.IRC });
|
||||
|
||||
expect(getLine(container).querySelector(".mx_EventTile_footer")).not.toBeNull();
|
||||
expect(getTile(container).querySelector(":scope > .mx_EventTile_footer")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a bubble footer for an own pinned message", () => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
const ownEvent = makeOwnMessage();
|
||||
const { container } = getComponent({ mxEvent: ownEvent, layout: Layout.Bubble });
|
||||
const footer = container.querySelector(".mx_EventTile_footer");
|
||||
|
||||
expect(footer).not.toBeNull();
|
||||
expect(footer).toHaveTextContent("Pinned message");
|
||||
});
|
||||
|
||||
it("renders relation groups and deduplicates reactions from the same sender", () => {
|
||||
const bobReaction1 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍");
|
||||
const bobReaction2 = makeReactionEvent(room.roomId, mxEvent.getId()!, "@bob:example.org", "👍");
|
||||
const getRelationsForEvent = jest
|
||||
.fn()
|
||||
.mockReturnValue(makeRelations(new Map([["👍", [bobReaction1, bobReaction2]]])));
|
||||
|
||||
getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, {
|
||||
canReact: true,
|
||||
});
|
||||
|
||||
const reactionButton = screen.getByRole("button", { name: /@bob:example\.org reacted with 👍/ });
|
||||
expect(reactionButton).toHaveTextContent("👍1");
|
||||
});
|
||||
|
||||
it("detects the current user's reaction when rendering relation groups", () => {
|
||||
const ownReaction = makeReactionEvent(room.roomId, mxEvent.getId()!, client.getSafeUserId(), "👍");
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue(
|
||||
makeRelations(new Map([["👍", [ownReaction]]]), {
|
||||
[client.getSafeUserId()]: [ownReaction],
|
||||
}),
|
||||
);
|
||||
|
||||
getComponent({ showReactions: true, getRelationsForEvent }, TimelineRenderingType.Room, {
|
||||
canReact: true,
|
||||
canSelfRedact: false,
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /reacted with 👍/ })).toHaveAttribute("aria-disabled", "true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("action bar", () => {
|
||||
@@ -907,6 +1067,20 @@ describe("EventTile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("renders a notice when the event has no renderer", () => {
|
||||
const unsupportedEvent = mkEvent({
|
||||
event: true,
|
||||
type: "org.example.unsupported",
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: {},
|
||||
});
|
||||
|
||||
getComponent({ mxEvent: unsupportedEvent });
|
||||
|
||||
expect(screen.getByText("This event could not be displayed")).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();
|
||||
@@ -1251,6 +1425,57 @@ describe("EventTile", () => {
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(1);
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")[0]).toHaveAccessibleName("Not encrypted");
|
||||
});
|
||||
|
||||
it.each([EventStatus.ENCRYPTING, EventStatus.NOT_SENT])(
|
||||
"does not show the unencrypted warning for %s events in encrypted rooms",
|
||||
(status) => {
|
||||
const event = makeOwnMessage();
|
||||
event.setStatus(status);
|
||||
const { container } = getComponent(
|
||||
{ mxEvent: event, eventSendStatus: status },
|
||||
TimelineRenderingType.Room,
|
||||
{
|
||||
isRoomEncrypted: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not show the unencrypted warning for state events in encrypted rooms", () => {
|
||||
const stateEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomTopic,
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
skey: "",
|
||||
content: { topic: "Topic" },
|
||||
});
|
||||
const { container } = getComponent({ mxEvent: stateEvent }, TimelineRenderingType.Room, {
|
||||
isRoomEncrypted: true,
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not show the unencrypted warning for redacted events in encrypted rooms", () => {
|
||||
jest.spyOn(mxEvent, "isRedacted").mockReturnValue(true);
|
||||
const { container } = getComponent({}, TimelineRenderingType.Room, {
|
||||
isRoomEncrypted: true,
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does not show the unencrypted warning for local-room events in encrypted rooms", () => {
|
||||
const localEvent = makeTimestampedMessage({ room: "local+room" });
|
||||
const { container } = getComponent({ mxEvent: localEvent }, TimelineRenderingType.Room, {
|
||||
isRoomEncrypted: true,
|
||||
});
|
||||
|
||||
expect(container.getElementsByClassName("mx_EventTile_e2eIcon")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event highlighting", () => {
|
||||
|
||||
@@ -65,6 +65,18 @@ describe("AudioPlayerViewModel", () => {
|
||||
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
|
||||
});
|
||||
|
||||
it("does not stop propagation for unhandled key down events", () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
|
||||
const event = new KeyboardEvent("keydown", { key: "a" });
|
||||
const stopPropagationSpy = jest.spyOn(event, "stopPropagation");
|
||||
|
||||
vm.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLDivElement>);
|
||||
|
||||
expect(stopPropagationSpy).not.toHaveBeenCalled();
|
||||
expect(playback.toggle).not.toHaveBeenCalled();
|
||||
expect(playback.skipTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should update snapshot when setProps is called with new mediaName", () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "oldName" });
|
||||
expect(vm.getSnapshot().mediaName).toBe("oldName");
|
||||
|
||||
@@ -119,4 +119,68 @@ describe("MessageTimestampViewModel", () => {
|
||||
href: "https://example.test",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the timestamp and received timestamp", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
|
||||
vm.setTimestamp(nowDate.getTime() + HOUR_MS);
|
||||
vm.setReceivedTimestamp(nowDate.getTime() + DAY_MS);
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "09:09",
|
||||
tsSentAt: "Fri, Dec 17, 2021, 09:09:00",
|
||||
tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates display options", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
|
||||
vm.setDisplayOptions({
|
||||
showTwelveHour: true,
|
||||
showSeconds: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "8:09:00 AM",
|
||||
tsSentAt: "Fri, Dec 17, 2021, 8:09:00 AM",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates tooltip, href, and handlers", () => {
|
||||
const onClick = jest.fn();
|
||||
const onContextMenu = jest.fn();
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
|
||||
vm.setTooltipInhibited(true);
|
||||
vm.setHref("https://example.test/event");
|
||||
vm.setHandlers({ onClick, onContextMenu });
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
inhibitTooltip: true,
|
||||
href: "https://example.test/event",
|
||||
});
|
||||
expect(vm.onClick).toBe(onClick);
|
||||
expect(vm.onContextMenu).toBe(onContextMenu);
|
||||
});
|
||||
|
||||
it("does not emit an update when props are unchanged", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
href: "https://example.test/event",
|
||||
});
|
||||
const listener = jest.fn();
|
||||
vm.subscribe(listener);
|
||||
|
||||
vm.setTimestamp(nowDate.getTime());
|
||||
vm.setHref("https://example.test/event");
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,9 @@ import { createTestClient, mkEvent, mkStubRoom } from "../../test-utils";
|
||||
import dis from "../../../src/dispatcher/dispatcher";
|
||||
|
||||
jest.mock("../../../src/dispatcher/dispatcher");
|
||||
jest.mock("../../../src/customisations/Media", () => ({
|
||||
mediaFromMxc: jest.fn(() => ({ srcHttp: "https://example.org/_matrix/media/reaction.png" })),
|
||||
}));
|
||||
|
||||
describe("ReactionsRowButtonViewModel", () => {
|
||||
let client: MatrixClient;
|
||||
@@ -91,6 +94,35 @@ describe("ReactionsRowButtonViewModel", () => {
|
||||
expect(getAriaLabel(vm)).toContain("reacted with 👍");
|
||||
});
|
||||
|
||||
it("falls back when no room is available", () => {
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(null);
|
||||
|
||||
const vm = new ReactionsRowButtonViewModel(createProps());
|
||||
|
||||
expect(getAriaLabel(vm)).toBeUndefined();
|
||||
expect(vm.getSnapshot().content).toBe("👍");
|
||||
expect(vm.getSnapshot().count).toBe(2);
|
||||
});
|
||||
|
||||
it("renders custom reaction images with shortcode labels when enabled", () => {
|
||||
const reactionEvent = createReactionEvent("@alice:example.org", "mxc://example.org/reaction");
|
||||
reactionEvent.getContent()["shortcode"] = "party";
|
||||
|
||||
const vm = new ReactionsRowButtonViewModel(
|
||||
createProps({
|
||||
content: "mxc://example.org/reaction",
|
||||
reactionEvents: [reactionEvent],
|
||||
customReactionImagesEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
imageSrc: "https://example.org/_matrix/media/reaction.png",
|
||||
imageAlt: "party",
|
||||
});
|
||||
expect(getAriaLabel(vm)).toContain("reacted with party");
|
||||
});
|
||||
|
||||
it("updates selected state with myReactionEvent without touching tooltip props", () => {
|
||||
const vm = new ReactionsRowButtonViewModel(createProps());
|
||||
const tooltipSetPropsSpy = jest.spyOn(getTooltipVm(vm), "setProps");
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* 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 { ActionBarAction } from "@element-hq/web-shared-components";
|
||||
|
||||
import { ThreadListActionBarViewModel } from "../../../src/viewmodels/room/ThreadListActionBarViewModel";
|
||||
|
||||
describe("ThreadListActionBarViewModel", () => {
|
||||
it("builds the thread-list action bar snapshot", () => {
|
||||
const vm = new ThreadListActionBarViewModel({});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
actions: [ActionBarAction.ViewInRoom, ActionBarAction.CopyLink],
|
||||
presentation: "icon",
|
||||
isDownloadEncrypted: false,
|
||||
isDownloadLoading: false,
|
||||
isPinned: false,
|
||||
isQuoteExpanded: false,
|
||||
isThreadReplyAllowed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards actions to the configured handlers", () => {
|
||||
const onViewInRoomClick = jest.fn();
|
||||
const onCopyLinkClick = jest.fn();
|
||||
const vm = new ThreadListActionBarViewModel({
|
||||
onViewInRoomClick,
|
||||
onCopyLinkClick,
|
||||
});
|
||||
const anchor = document.createElement("button");
|
||||
|
||||
vm.onViewInRoomClick(anchor);
|
||||
vm.onCopyLinkClick(anchor);
|
||||
|
||||
expect(onViewInRoomClick).toHaveBeenCalledWith(anchor);
|
||||
expect(onCopyLinkClick).toHaveBeenCalledWith(anchor);
|
||||
});
|
||||
|
||||
it("uses updated handlers after setProps", () => {
|
||||
const initialOnViewInRoomClick = jest.fn();
|
||||
const nextOnViewInRoomClick = jest.fn();
|
||||
const vm = new ThreadListActionBarViewModel({
|
||||
onViewInRoomClick: initialOnViewInRoomClick,
|
||||
});
|
||||
const anchor = document.createElement("button");
|
||||
|
||||
vm.setProps({ onViewInRoomClick: nextOnViewInRoomClick });
|
||||
vm.onViewInRoomClick(anchor);
|
||||
|
||||
expect(initialOnViewInRoomClick).not.toHaveBeenCalled();
|
||||
expect(nextOnViewInRoomClick).toHaveBeenCalledWith(anchor);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user