mv element.io @types __mocks__/ debian docker module_system/ playwright res src test webapp Dockerfile .dockerignore .eslintignore .stylelintrc.cjs babel.config.cjs recorder-worklet-loader.cjs .modernizr.json components.json config.json config.sample.json package.json project.json tsconfig.json tsconfig.module_system.json jest.config.ts playwright.config.ts webpack.config.ts build_config.sample.yaml apps/web/
mkdir apps/web/scripts
mv scripts/{cleanup.sh,ci_package.sh,copy-res.ts,deploy.py,package.sh} apps/web/scripts
And a couple of gitignore tweaks
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 ChangeEvent, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import { waitFor } from "@testing-library/dom";
|
||||
|
||||
import { type Playback, PlaybackState } from "../../../src/audio/Playback";
|
||||
import { AudioPlayerViewModel } from "../../../src/viewmodels/audio/AudioPlayerViewModel";
|
||||
import { MockedPlayback } from "../../unit-tests/audio/MockedPlayback";
|
||||
|
||||
describe("AudioPlayerViewModel", () => {
|
||||
let playback: Playback;
|
||||
beforeEach(() => {
|
||||
playback = new MockedPlayback(PlaybackState.Decoding, 50, 10) as unknown as Playback;
|
||||
});
|
||||
|
||||
it("should return the snapshot", () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
mediaName: "mediaName",
|
||||
sizeBytes: 8000,
|
||||
playbackState: "decoding",
|
||||
durationSeconds: 50,
|
||||
playedSeconds: 10,
|
||||
percentComplete: 20,
|
||||
error: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should toggle the playback state", async () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
|
||||
|
||||
await vm.togglePlay();
|
||||
expect(playback.toggle).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should move the playback on seekbar change", async () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
|
||||
await vm.onSeekbarChange({ target: { value: "20" } } as ChangeEvent<HTMLInputElement>);
|
||||
expect(playback.skipTo).toHaveBeenCalledWith(10); // 20% of 50 seconds
|
||||
});
|
||||
|
||||
it("should has error=true when playback.prepare fails", async () => {
|
||||
jest.spyOn(playback, "prepare").mockRejectedValue(new Error("Failed to prepare playback"));
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
|
||||
await waitFor(() => expect(vm.getSnapshot().error).toBe(true));
|
||||
});
|
||||
|
||||
it("should handle key down events", () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "mediaName" });
|
||||
let event = new KeyboardEvent("keydown", { key: " " }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
|
||||
vm.onKeyDown(event);
|
||||
expect(playback.toggle).toHaveBeenCalled();
|
||||
|
||||
event = new KeyboardEvent("keydown", { key: "ArrowLeft" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
|
||||
vm.onKeyDown(event);
|
||||
expect(playback.skipTo).toHaveBeenCalledWith(10 - 5); // 5 seconds back
|
||||
|
||||
event = new KeyboardEvent("keydown", { key: "ArrowRight" }) as unknown as ReactKeyboardEvent<HTMLDivElement>;
|
||||
vm.onKeyDown(event);
|
||||
expect(playback.skipTo).toHaveBeenCalledWith(10 + 5); // 5 seconds forward
|
||||
});
|
||||
|
||||
it("should update snapshot when setProps is called with new mediaName", () => {
|
||||
const vm = new AudioPlayerViewModel({ playback, mediaName: "oldName" });
|
||||
expect(vm.getSnapshot().mediaName).toBe("oldName");
|
||||
|
||||
vm.setProps({ mediaName: "newName" });
|
||||
expect(vm.getSnapshot().mediaName).toBe("newName");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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 { waitFor } from "@testing-library/dom";
|
||||
import { mocked } from "jest-mock";
|
||||
import { RoomStateEvent, type MatrixClient, type MatrixEvent, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { EncryptionEventState } from "@element-hq/web-shared-components";
|
||||
|
||||
import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
|
||||
import { EncryptionEventViewModel } from "../../../src/viewmodels/event-tiles/EncryptionEventViewModel";
|
||||
import { LocalRoom } from "../../../src/models/LocalRoom";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
import { mkEvent, stubClient } from "../../test-utils";
|
||||
|
||||
describe("EncryptionEventViewModel", () => {
|
||||
const roomId = "!room:example.com";
|
||||
const algorithm = "m.megolm.v1.aes-sha2";
|
||||
let client: MatrixClient;
|
||||
let event: MatrixEvent;
|
||||
let room: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
client = stubClient();
|
||||
room = client.getRoom(roomId)!;
|
||||
mocked(client.getRoom).mockReturnValue(room);
|
||||
event = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: client.getUserId()!,
|
||||
type: "m.room.encryption",
|
||||
content: {
|
||||
algorithm,
|
||||
},
|
||||
prev_content: {},
|
||||
});
|
||||
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
} as unknown as DMRoomMap);
|
||||
});
|
||||
|
||||
const setRoomEncrypted = (encrypted: boolean): void => {
|
||||
const crypto = client.getCrypto()!;
|
||||
mocked(crypto.isEncryptionEnabledInRoom).mockResolvedValue(encrypted);
|
||||
};
|
||||
|
||||
const createVm = (
|
||||
props: Partial<ConstructorParameters<typeof EncryptionEventViewModel>[0]> = {},
|
||||
): EncryptionEventViewModel =>
|
||||
new EncryptionEventViewModel({
|
||||
mxEvent: event,
|
||||
cli: client,
|
||||
...props,
|
||||
});
|
||||
|
||||
it("sets ENABLED for encrypted room", async () => {
|
||||
setRoomEncrypted(true);
|
||||
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
state: EncryptionEventState.ENABLED,
|
||||
className: "mx_EventTileBubble mx_cryptoEvent mx_cryptoEvent_icon",
|
||||
encryptedStateEvents: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses synchronous room encryption state for the initial snapshot", () => {
|
||||
jest.spyOn(room, "hasEncryptionStateEvent").mockReturnValue(true);
|
||||
setRoomEncrypted(false);
|
||||
|
||||
const vm = createVm();
|
||||
expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED);
|
||||
});
|
||||
|
||||
it("sets ENABLED with encryptedStateEvents=true for encrypted state events", async () => {
|
||||
setRoomEncrypted(true);
|
||||
client.enableEncryptedStateEvents = true;
|
||||
(event.getContent() as RoomEncryptionEventContent)["io.element.msc4362.encrypt_state_events"] = true;
|
||||
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
|
||||
expect(vm.getSnapshot().encryptedStateEvents).toBe(true);
|
||||
});
|
||||
|
||||
it("sets CHANGED when previous algorithm is already megolm", async () => {
|
||||
setRoomEncrypted(true);
|
||||
event = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: client.getUserId()!,
|
||||
type: "m.room.encryption",
|
||||
content: {
|
||||
algorithm,
|
||||
rotation_period_ms: 1,
|
||||
},
|
||||
prev_content: { algorithm },
|
||||
});
|
||||
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.CHANGED));
|
||||
});
|
||||
|
||||
it("sets DISABLE_ATTEMPT for unknown algorithm in encrypted room", async () => {
|
||||
setRoomEncrypted(true);
|
||||
event = mkEvent({
|
||||
event: true,
|
||||
room: roomId,
|
||||
user: client.getUserId()!,
|
||||
type: "m.room.encryption",
|
||||
content: { algorithm: "unknown" },
|
||||
prev_content: {},
|
||||
});
|
||||
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.DISABLE_ATTEMPT));
|
||||
});
|
||||
|
||||
it("sets UNSUPPORTED for unencrypted room", async () => {
|
||||
setRoomEncrypted(false);
|
||||
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED));
|
||||
expect(vm.getSnapshot().className).toBe("mx_EventTileBubble mx_cryptoEvent");
|
||||
});
|
||||
|
||||
it("sets ENABLED_DM with partner display name", async () => {
|
||||
setRoomEncrypted(true);
|
||||
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
|
||||
getUserIdForRoomId: jest.fn().mockReturnValue("@alice:example.com"),
|
||||
} as unknown as DMRoomMap);
|
||||
mocked(room.getMember).mockReturnValue({
|
||||
rawDisplayName: "Alice",
|
||||
} as unknown as ReturnType<typeof room.getMember>);
|
||||
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED_DM));
|
||||
expect(vm.getSnapshot().userName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("sets ENABLED_LOCAL for encrypted local room", async () => {
|
||||
const localRoomId = "local+123";
|
||||
const localRoom = new LocalRoom(localRoomId, client, client.getUserId()!);
|
||||
jest.spyOn(localRoom, "isEncryptionEnabled").mockReturnValue(true);
|
||||
mocked(client.getRoom).mockReturnValue(localRoom);
|
||||
event = mkEvent({
|
||||
event: true,
|
||||
room: localRoomId,
|
||||
user: client.getUserId()!,
|
||||
type: "m.room.encryption",
|
||||
content: { algorithm },
|
||||
prev_content: {},
|
||||
});
|
||||
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
} as unknown as DMRoomMap);
|
||||
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED_LOCAL));
|
||||
expect(localRoom.isEncryptionEnabled).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recomputes snapshot on RoomStateEvent.Update", async () => {
|
||||
setRoomEncrypted(false);
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.UNSUPPORTED));
|
||||
|
||||
setRoomEncrypted(true);
|
||||
room.emit(RoomStateEvent.Update, room.currentState);
|
||||
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
|
||||
});
|
||||
|
||||
it("does not emit updates when snapshot is unchanged", async () => {
|
||||
setRoomEncrypted(true);
|
||||
const vm = createVm();
|
||||
await waitFor(() => expect(vm.getSnapshot().state).toBe(EncryptionEventState.ENABLED));
|
||||
|
||||
const listener = jest.fn();
|
||||
const unsubscribe = vm.subscribe(listener);
|
||||
|
||||
room.emit(RoomStateEvent.Update, room.currentState);
|
||||
|
||||
await waitFor(() => expect(mocked(client.getCrypto()!.isEncryptionEnabledInRoom)).toHaveBeenCalledTimes(2));
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { TextualEventViewModel } from "../../../src/viewmodels/event-tiles/TextualEventViewModel";
|
||||
import { stubClient } from "../../test-utils";
|
||||
|
||||
jest.mock("../../../src/TextForEvent.tsx", () => ({
|
||||
textForEvent: jest.fn().mockReturnValue("Test Message"),
|
||||
}));
|
||||
|
||||
describe("TextualEventViewModel", () => {
|
||||
it("should update when the sentinel updates", () => {
|
||||
const fakeEvent = new MatrixEvent({});
|
||||
stubClient();
|
||||
|
||||
const vm = new TextualEventViewModel({
|
||||
showHiddenEvents: false,
|
||||
mxEvent: fakeEvent,
|
||||
});
|
||||
|
||||
const cb = jest.fn();
|
||||
|
||||
vm.subscribe(cb);
|
||||
|
||||
fakeEvent.emit(MatrixEventEvent.SentinelUpdated);
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
* 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 { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
|
||||
import { DecryptionFailureReason } from "@element-hq/web-shared-components";
|
||||
|
||||
import { DecryptionFailureBodyViewModel } from "../../../src/viewmodels/message-body/DecryptionFailureBodyViewModel";
|
||||
|
||||
describe("DecryptionFailureBodyViewModel", () => {
|
||||
it("should return the snapshot", () => {
|
||||
const vm = new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: null,
|
||||
verificationState: true,
|
||||
});
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
isLocalDeviceVerified: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the snapshot with extra class names", () => {
|
||||
const vm = new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: null,
|
||||
verificationState: true,
|
||||
extraClassNames: ["custom-class"],
|
||||
});
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
decryptionFailureReason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
isLocalDeviceVerified: true,
|
||||
extraClassNames: ["mx_DecryptionFailureBody", "mx_EventTile_content", "custom-class"],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
code: DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
|
||||
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
|
||||
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_NO_KEY_BACKUP,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
|
||||
reason: DecryptionFailureReason.HISTORICAL_MESSAGE_USER_NOT_JOINED,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD,
|
||||
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
|
||||
reason: DecryptionFailureReason.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID,
|
||||
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX,
|
||||
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
|
||||
reason: DecryptionFailureReason.SENDER_IDENTITY_PREVIOUSLY_VERIFIED,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.UNKNOWN_SENDER_DEVICE,
|
||||
reason: DecryptionFailureReason.UNABLE_TO_DECRYPT,
|
||||
},
|
||||
{
|
||||
code: DecryptionFailureCode.UNSIGNED_SENDER_DEVICE,
|
||||
reason: DecryptionFailureReason.UNSIGNED_SENDER_DEVICE,
|
||||
},
|
||||
])("should return the snapshot with code converted to reason (%s)", ({ code, reason }) => {
|
||||
const vm = new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: code,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().decryptionFailureReason).toBe(reason);
|
||||
});
|
||||
|
||||
it("should update snapshot when setProps is called with new verificationState", () => {
|
||||
const vm = new DecryptionFailureBodyViewModel({
|
||||
decryptionFailureCode: DecryptionFailureCode.UNKNOWN_ERROR,
|
||||
verificationState: false,
|
||||
});
|
||||
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(false);
|
||||
|
||||
vm.setVerificationState(true);
|
||||
expect(vm.getSnapshot().isLocalDeviceVerified).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 * as DateUtils from "../../../src/DateUtils";
|
||||
import { MessageTimestampViewModel } from "../../../src/viewmodels/message-body/MessageTimestampViewModel";
|
||||
|
||||
jest.mock("../../../src/settings/SettingsStore");
|
||||
|
||||
describe("MessageTimestampViewModel", () => {
|
||||
// Friday Dec 17 2021, 9:09am
|
||||
const nowDate = new Date("2021-12-17T08:09:00.000Z");
|
||||
const HOUR_MS = 3600000;
|
||||
const DAY_MS = HOUR_MS * 24;
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should return the snapshot", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "08:09",
|
||||
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the snapshot with tsReceivedAt", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
receivedTs: nowDate.getTime() + DAY_MS,
|
||||
});
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "08:09",
|
||||
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
|
||||
tsReceivedAt: "Sat, Dec 18, 2021, 08:09:00",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the snapshot with extra class names", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
});
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "08:09",
|
||||
tsSentAt: "Fri, Dec 17, 2021, 08:09:00",
|
||||
className: "mx_MessageTimestamp",
|
||||
});
|
||||
});
|
||||
|
||||
it("should use formatRelativeTime when showRelative is true", () => {
|
||||
jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT");
|
||||
const formatRelativeTimeSpy = jest.spyOn(DateUtils, "formatRelativeTime").mockReturnValue("RELATIVE");
|
||||
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
showRelative: true,
|
||||
showTwelveHour: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "RELATIVE",
|
||||
tsSentAt: "SENT_AT",
|
||||
});
|
||||
expect(formatRelativeTimeSpy).toHaveBeenCalledWith(expect.any(Date), true);
|
||||
});
|
||||
|
||||
it("should use full date when showFullDate is true and respect showSeconds", () => {
|
||||
const formatFullDateSpy = jest
|
||||
.spyOn(DateUtils, "formatFullDate")
|
||||
.mockImplementation((_date, _showTwelveHour, showSeconds) =>
|
||||
showSeconds === false ? "FULL_NO_SECONDS" : "SENT_AT",
|
||||
);
|
||||
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
showFullDate: true,
|
||||
showSeconds: false,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "FULL_NO_SECONDS",
|
||||
tsSentAt: "SENT_AT",
|
||||
});
|
||||
expect(formatFullDateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use full time when showSeconds is true without full date", () => {
|
||||
jest.spyOn(DateUtils, "formatFullDate").mockReturnValue("SENT_AT");
|
||||
const formatFullTimeSpy = jest.spyOn(DateUtils, "formatFullTime").mockReturnValue("FULL_TIME");
|
||||
const formatTimeSpy = jest.spyOn(DateUtils, "formatTime").mockReturnValue("TIME");
|
||||
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
showSeconds: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
ts: "FULL_TIME",
|
||||
tsSentAt: "SENT_AT",
|
||||
});
|
||||
expect(formatFullTimeSpy).toHaveBeenCalled();
|
||||
expect(formatTimeSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should include tooltip inhibition and href in the snapshot", () => {
|
||||
const vm = new MessageTimestampViewModel({
|
||||
ts: nowDate.getTime(),
|
||||
inhibitTooltip: true,
|
||||
href: "https://example.test",
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
inhibitTooltip: true,
|
||||
href: "https://example.test",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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 MatrixClient, type MatrixEvent, type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import {
|
||||
ReactionsRowButtonTooltipViewModel,
|
||||
type ReactionsRowButtonTooltipViewModelProps,
|
||||
} from "../../../src/viewmodels/message-body/ReactionsRowButtonTooltipViewModel";
|
||||
import { stubClient, mkStubRoom, mkEvent } from "../../test-utils";
|
||||
import { unicodeToShortcode } from "../../../src/HtmlUtils";
|
||||
|
||||
jest.mock("../../../src/HtmlUtils", () => ({
|
||||
...jest.requireActual("../../../src/HtmlUtils"),
|
||||
unicodeToShortcode: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedUnicodeToShortcode = jest.mocked(unicodeToShortcode);
|
||||
|
||||
describe("ReactionsRowButtonTooltipViewModel", () => {
|
||||
let client: MatrixClient;
|
||||
let room: Room;
|
||||
let mxEvent: MatrixEvent;
|
||||
|
||||
const createReactionEvent = (senderId: string, content?: Record<string, unknown>): MatrixEvent => {
|
||||
return mkEvent({
|
||||
event: true,
|
||||
type: "m.reaction",
|
||||
room: room.roomId,
|
||||
user: senderId,
|
||||
content: {
|
||||
"m.relates_to": { rel_type: "m.annotation", event_id: mxEvent.getId(), key: "👍" },
|
||||
...content,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createProps = (
|
||||
overrides?: Partial<ReactionsRowButtonTooltipViewModelProps>,
|
||||
): ReactionsRowButtonTooltipViewModelProps => ({
|
||||
client,
|
||||
mxEvent,
|
||||
content: "👍",
|
||||
reactionEvents: [],
|
||||
customReactionImagesEnabled: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient();
|
||||
room = mkStubRoom("!room:example.org", "Test Room", client);
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(room);
|
||||
|
||||
mxEvent = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.message",
|
||||
room: room.roomId,
|
||||
user: "@sender:example.org",
|
||||
content: { body: "Test message", msgtype: "m.text" },
|
||||
});
|
||||
|
||||
mockedUnicodeToShortcode.mockImplementation((char: string) => {
|
||||
if (char === "👍") return ":thumbsup:";
|
||||
return "";
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
mockedUnicodeToShortcode.mockReset();
|
||||
});
|
||||
|
||||
it("should return undefined snapshot when room is not found", () => {
|
||||
jest.spyOn(client, "getRoom").mockReturnValue(null);
|
||||
|
||||
const vm = new ReactionsRowButtonTooltipViewModel(createProps());
|
||||
const snapshot = vm.getSnapshot();
|
||||
|
||||
expect(snapshot.formattedSenders).toBeUndefined();
|
||||
expect(snapshot.caption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined snapshot when MatrixClient is unavailable", () => {
|
||||
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ client: null }));
|
||||
const snapshot = vm.getSnapshot();
|
||||
|
||||
expect(snapshot.formattedSenders).toBeUndefined();
|
||||
expect(snapshot.caption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should compute formattedSenders and caption from reaction events", () => {
|
||||
const reactionEvent = createReactionEvent("@alice:example.org");
|
||||
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
|
||||
|
||||
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] }));
|
||||
const snapshot = vm.getSnapshot();
|
||||
|
||||
expect(snapshot.formattedSenders).toBe("Alice");
|
||||
expect(snapshot.caption).toContain(":thumbsup:");
|
||||
});
|
||||
|
||||
it("should fall back to sender ID when member is not found", () => {
|
||||
const reactionEvent = createReactionEvent("@unknown:example.org");
|
||||
jest.spyOn(room, "getMember").mockReturnValue(null);
|
||||
|
||||
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [reactionEvent] }));
|
||||
|
||||
expect(vm.getSnapshot().formattedSenders).toBe("@unknown:example.org");
|
||||
});
|
||||
|
||||
it("should use custom reaction shortcode when customReactionImagesEnabled is true", () => {
|
||||
mockedUnicodeToShortcode.mockReturnValue("");
|
||||
const reactionEvent = createReactionEvent("@alice:example.org", {
|
||||
"com.beeper.reaction.shortcode": "custom_emoji",
|
||||
});
|
||||
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
|
||||
|
||||
const vm = new ReactionsRowButtonTooltipViewModel(
|
||||
createProps({
|
||||
content: "mxc://custom/emoji",
|
||||
reactionEvents: [reactionEvent],
|
||||
customReactionImagesEnabled: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vm.getSnapshot().caption).toContain("custom_emoji");
|
||||
});
|
||||
|
||||
it("should not use custom reaction shortcode when customReactionImagesEnabled is false", () => {
|
||||
mockedUnicodeToShortcode.mockReturnValue("");
|
||||
const reactionEvent = createReactionEvent("@alice:example.org", {
|
||||
"com.beeper.reaction.shortcode": "custom_emoji",
|
||||
});
|
||||
jest.spyOn(room, "getMember").mockReturnValue({ name: "Alice", userId: "@alice:example.org" } as RoomMember);
|
||||
|
||||
const vm = new ReactionsRowButtonTooltipViewModel(
|
||||
createProps({
|
||||
content: "mxc://custom/emoji",
|
||||
reactionEvents: [reactionEvent],
|
||||
customReactionImagesEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(vm.getSnapshot().caption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should update snapshot and notify subscribers when setProps is called", () => {
|
||||
const aliceReaction = createReactionEvent("@alice:example.org");
|
||||
const bobReaction = createReactionEvent("@bob:example.org");
|
||||
|
||||
jest.spyOn(room, "getMember").mockImplementation((userId) => {
|
||||
const names: Record<string, string> = { "@alice:example.org": "Alice", "@bob:example.org": "Bob" };
|
||||
return names[userId!] ? ({ name: names[userId!], userId } as RoomMember) : null;
|
||||
});
|
||||
|
||||
const vm = new ReactionsRowButtonTooltipViewModel(createProps({ reactionEvents: [aliceReaction] }));
|
||||
expect(vm.getSnapshot().formattedSenders).toBe("Alice");
|
||||
|
||||
const subscriber = jest.fn();
|
||||
vm.subscribe(subscriber);
|
||||
|
||||
vm.setProps({ reactionEvents: [aliceReaction, bobReaction] });
|
||||
|
||||
expect(subscriber).toHaveBeenCalled();
|
||||
expect(vm.getSnapshot().formattedSenders).toContain("Alice");
|
||||
expect(vm.getSnapshot().formattedSenders).toContain("Bob");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
* 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 { DisambiguatedProfileViewModel } from "../../../src/viewmodels/profile/DisambiguatedProfileViewModel";
|
||||
|
||||
describe("DisambiguatedProfileViewModel", () => {
|
||||
const member = {
|
||||
userId: "@alice:example.org",
|
||||
roomId: "!room:example.org",
|
||||
rawDisplayName: "Alice",
|
||||
disambiguate: true,
|
||||
};
|
||||
const nonDisambiguatedMember = {
|
||||
...member,
|
||||
disambiguate: false,
|
||||
};
|
||||
|
||||
it("should return the snapshot from props", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member,
|
||||
fallbackName: "Fallback",
|
||||
colored: true,
|
||||
emphasizeDisplayName: true,
|
||||
withTooltip: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
displayName: "Alice",
|
||||
colorClass: "mx_Username_color3",
|
||||
className: undefined,
|
||||
displayIdentifier: "@alice:example.org",
|
||||
title: "Alice (@alice:example.org)",
|
||||
emphasizeDisplayName: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("should default member fields when member is null", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member: null,
|
||||
fallbackName: "Fallback",
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
displayName: "Fallback",
|
||||
colorClass: undefined,
|
||||
className: undefined,
|
||||
displayIdentifier: undefined,
|
||||
title: undefined,
|
||||
emphasizeDisplayName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should pass through className prop", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member,
|
||||
fallbackName: "Fallback",
|
||||
className: "mx_DisambiguatedProfile",
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().className).toBe("mx_DisambiguatedProfile");
|
||||
});
|
||||
|
||||
it("should delegate onClick without emitting a snapshot update", () => {
|
||||
const onClick = jest.fn();
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member,
|
||||
fallbackName: "Fallback",
|
||||
onClick,
|
||||
});
|
||||
const prevSnapshot = vm.getSnapshot();
|
||||
const subscriber = jest.fn();
|
||||
|
||||
vm.subscribe(subscriber);
|
||||
vm.onClick?.({} as never);
|
||||
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
expect(subscriber).not.toHaveBeenCalled();
|
||||
expect(vm.getSnapshot()).toBe(prevSnapshot);
|
||||
});
|
||||
|
||||
it("should keep onClick bound when extracted as a callback", () => {
|
||||
const onClick = jest.fn();
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member,
|
||||
fallbackName: "Fallback",
|
||||
onClick,
|
||||
});
|
||||
|
||||
const clickHandler = vm.onClick;
|
||||
|
||||
expect(() => clickHandler?.({} as never)).not.toThrow();
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should emit snapshot update when fallbackName changes", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member: null,
|
||||
fallbackName: "Fallback",
|
||||
});
|
||||
const subscriber = jest.fn();
|
||||
|
||||
vm.subscribe(subscriber);
|
||||
vm.setMember("Updated");
|
||||
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
expect(vm.getSnapshot().displayName).toBe("Updated");
|
||||
});
|
||||
|
||||
it("should emit snapshot update when setMember is called even if fallbackName is unchanged", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member: null,
|
||||
fallbackName: "Fallback",
|
||||
});
|
||||
const subscriber = jest.fn();
|
||||
|
||||
vm.subscribe(subscriber);
|
||||
vm.setMember("Fallback");
|
||||
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should compute tooltip title from constructor props when withTooltip is true", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member,
|
||||
fallbackName: "Fallback",
|
||||
withTooltip: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)");
|
||||
});
|
||||
|
||||
it("should compute tooltip title even when disambiguation is not needed", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member: nonDisambiguatedMember,
|
||||
fallbackName: "Fallback",
|
||||
withTooltip: true,
|
||||
});
|
||||
|
||||
expect(vm.getSnapshot().title).toBe("Alice (@alice:example.org)");
|
||||
});
|
||||
|
||||
it("should emit snapshot update when member changes via setMember", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member: null,
|
||||
fallbackName: "Fallback",
|
||||
});
|
||||
const subscriber = jest.fn();
|
||||
|
||||
vm.subscribe(subscriber);
|
||||
vm.setMember("Fallback", member);
|
||||
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
expect(vm.getSnapshot().displayName).toBe("Alice");
|
||||
});
|
||||
|
||||
it("should emit snapshot update when setMember is called with unchanged member", () => {
|
||||
const vm = new DisambiguatedProfileViewModel({
|
||||
member,
|
||||
fallbackName: "Fallback",
|
||||
});
|
||||
const subscriber = jest.fn();
|
||||
|
||||
vm.subscribe(subscriber);
|
||||
vm.setMember("Fallback", member);
|
||||
|
||||
expect(subscriber).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 React from "react";
|
||||
import { MatrixWidgetType } from "matrix-widget-api";
|
||||
import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import {
|
||||
WidgetContextMenuViewModel,
|
||||
type WidgetContextMenuViewModelProps,
|
||||
} from "../../../src/viewmodels/right-panel/WidgetContextMenuViewModel";
|
||||
import { stubClient } from "../../test-utils";
|
||||
import WidgetUtils from "../../../src/utils/WidgetUtils";
|
||||
import { type IApp } from "../../../src/utils/WidgetUtils-types";
|
||||
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import * as livestream from "../../../src/Livestream";
|
||||
import Modal from "../../../src/Modal";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import * as widgetStore from "../../../src/stores/WidgetStore";
|
||||
import { WidgetMessagingStore } from "../../../src/stores/widgets/WidgetMessagingStore";
|
||||
import { type WidgetMessaging } from "../../../src/stores/widgets/WidgetMessaging";
|
||||
|
||||
describe("WidgetContextMenuViewModel", () => {
|
||||
const widgetId = "w1";
|
||||
const eventId = "e1";
|
||||
const roomId = "r1";
|
||||
const userId = "@user-id:server";
|
||||
|
||||
const app: IApp = {
|
||||
id: widgetId,
|
||||
eventId,
|
||||
roomId,
|
||||
type: MatrixWidgetType.Custom,
|
||||
url: "https://example.com",
|
||||
name: "Example 1",
|
||||
creatorUserId: userId,
|
||||
avatar_url: undefined,
|
||||
};
|
||||
|
||||
let client: MatrixClient;
|
||||
const defaultProps: WidgetContextMenuViewModelProps = {
|
||||
menuDisplayed: true,
|
||||
room: undefined,
|
||||
roomId,
|
||||
cli: stubClient(),
|
||||
app,
|
||||
showUnpin: true,
|
||||
userWidget: true,
|
||||
trigger: <></>,
|
||||
onEditClick: jest.fn(),
|
||||
onDeleteClick: jest.fn(),
|
||||
onFinished: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(WidgetUtils, "canUserModifyWidgets").mockReturnValue(true);
|
||||
jest.spyOn(WidgetUtils, "isManagedByManager").mockReturnValue(true);
|
||||
jest.spyOn(WidgetUtils, "editWidget").mockReturnValue();
|
||||
const mockMessaging = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
stop: () => {},
|
||||
widgetApi: {
|
||||
hasCapability: jest.fn(),
|
||||
},
|
||||
} as unknown as WidgetMessaging;
|
||||
jest.spyOn(WidgetMessagingStore.instance, "getMessagingForUid").mockReturnValue(mockMessaging);
|
||||
client = stubClient();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should return the snapshot", () => {
|
||||
const vm = new WidgetContextMenuViewModel(defaultProps);
|
||||
expect(vm.getSnapshot()).toMatchObject({
|
||||
showStreamAudioStreamButton: false, // because widget type is custom and not jitsi
|
||||
showEditButton: true, // because default mock return true on canUserModifyWidgets and isManagedByManager
|
||||
showRevokeButton: false,
|
||||
showDeleteButton: true,
|
||||
showSnapshotButton: false, // because no default value for sdkconfig "enableWidgetScreenshots"
|
||||
showMoveButtons: [false, false],
|
||||
canModify: true,
|
||||
isMenuOpened: true,
|
||||
trigger: <></>,
|
||||
});
|
||||
});
|
||||
|
||||
it("should call edit widget no custom edit function passed and room exist", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
onEditClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onEditClick();
|
||||
expect(WidgetUtils.editWidget).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call custom onEditClick if passed as props and room exist", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onEditClick();
|
||||
|
||||
expect(props.onEditClick).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should just call finish if no custom onEditClick is passed as props and does not room exist", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
onEditClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onEditClick();
|
||||
|
||||
expect(WidgetUtils.editWidget).not.toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should move widget position when onmovebutton is called", () => {
|
||||
jest.spyOn(WidgetLayoutStore.instance, "moveWithinContainer").mockReturnValue();
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onMoveButton(1);
|
||||
|
||||
expect(WidgetLayoutStore.instance.moveWithinContainer).toHaveBeenCalledWith(
|
||||
props.room,
|
||||
Container.Top,
|
||||
props.app,
|
||||
1,
|
||||
);
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw error when onmovebutton is called and no room is given", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
expect(() => vm.onMoveButton(1)).toThrow();
|
||||
});
|
||||
|
||||
it("should startJitsiAudioLivestream when onStreamAudioClick button is clicked", async () => {
|
||||
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(jest.fn());
|
||||
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onStreamAudioClick();
|
||||
await expect(livestream.startJitsiAudioLivestream).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should show modal when startJitsiAudioLivestream is on error and onStreamAudioClick button is clicked", async () => {
|
||||
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(() => {
|
||||
console.log("failllllled");
|
||||
throw new Error("Failed");
|
||||
});
|
||||
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({
|
||||
finished: Promise.resolve([true, true, false]),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
await vm.onStreamAudioClick();
|
||||
expect(Modal.createDialog).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should throw when no room is given and onStreamAudioClick button is clicked", async () => {
|
||||
jest.spyOn(livestream, "startJitsiAudioLivestream").mockImplementation(jest.fn());
|
||||
jest.spyOn(livestream, "getConfigLivestreamUrl").mockReturnValue("https://url");
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
await vm.onStreamAudioClick();
|
||||
// nothing happened
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call custom delete function when it is given in props", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
vm.onDeleteClick();
|
||||
expect(props.onDeleteClick).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should display modal when no custom function is provided and a room is given", () => {
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({
|
||||
finished: Promise.resolve([true, true, false]),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
onDeleteClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
vm.onDeleteClick();
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalled();
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should do nothing when onDeleteClick and no custom function and no room is provided", () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
onDeleteClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
vm.onDeleteClick();
|
||||
|
||||
expect(props.onFinished).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set new level for allowedwidget when onrevoke button is clicked", () => {
|
||||
const current = { [eventId]: true };
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(current);
|
||||
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(SettingLevel.DEFAULT);
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue();
|
||||
jest.spyOn(widgetStore, "isAppWidget").mockReturnValue(true);
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: new Room(roomId, client, userId),
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
vm.onRevokeClick();
|
||||
|
||||
expect(SettingsStore.setValue).toHaveBeenCalledWith(
|
||||
"allowedWidgets",
|
||||
props.roomId,
|
||||
SettingLevel.DEFAULT,
|
||||
current,
|
||||
);
|
||||
|
||||
const current2 = { [eventId]: false };
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(current2);
|
||||
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(SettingLevel.DEFAULT);
|
||||
jest.spyOn(SettingsStore, "setValue").mockResolvedValue();
|
||||
jest.spyOn(widgetStore, "isAppWidget").mockReturnValue(false);
|
||||
|
||||
vm.onRevokeClick();
|
||||
|
||||
expect(SettingsStore.setValue).toHaveBeenCalledWith(
|
||||
"allowedWidgets",
|
||||
props.roomId,
|
||||
SettingLevel.DEFAULT,
|
||||
current2,
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error when first supported level is not set", () => {
|
||||
jest.spyOn(SettingsStore, "firstSupportedLevel").mockReturnValue(null);
|
||||
const props = {
|
||||
...defaultProps,
|
||||
room: undefined,
|
||||
onDeleteClick: undefined,
|
||||
};
|
||||
const vm = new WidgetContextMenuViewModel(props);
|
||||
|
||||
expect(() => vm.onRevokeClick()).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* 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 { mocked } from "jest-mock";
|
||||
import { JoinRule, type MatrixClient, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { RoomListHeaderViewModel } from "../../../src/viewmodels/room-list/RoomListHeaderViewModel";
|
||||
import { MetaSpace, UPDATE_HOME_BEHAVIOUR, UPDATE_SELECTED_SPACE } from "../../../src/stores/spaces";
|
||||
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { SortingAlgorithm } from "../../../src/stores/room-list-v3/skip-list/sorters";
|
||||
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import {
|
||||
shouldShowSpaceSettings,
|
||||
showCreateNewRoom,
|
||||
showSpaceInvite,
|
||||
showSpacePreferences,
|
||||
showSpaceSettings,
|
||||
} from "../../../src/utils/space";
|
||||
import { createTestClient, mkSpace } from "../../test-utils";
|
||||
import { createRoom, hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
|
||||
import PosthogTrackers from "../../../src/PosthogTrackers";
|
||||
|
||||
jest.mock("../../../src/PosthogTrackers", () => ({
|
||||
trackInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/space", () => ({
|
||||
shouldShowSpaceSettings: jest.fn(),
|
||||
showCreateNewRoom: jest.fn(),
|
||||
showSpaceInvite: jest.fn(),
|
||||
showSpacePreferences: jest.fn(),
|
||||
showSpaceSettings: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
createRoom: jest.fn(),
|
||||
hasCreateRoomRights: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("RoomListHeaderViewModel", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let mockSpace: Room;
|
||||
let vm: RoomListHeaderViewModel;
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = createTestClient();
|
||||
|
||||
mockSpace = mkSpace(matrixClient, "!space:server");
|
||||
|
||||
mocked(hasCreateRoomRights).mockReturnValue(true);
|
||||
mocked(shouldShowSpaceSettings).mockReturnValue(true);
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Recency;
|
||||
if (settingName === "feature_video_rooms") return true;
|
||||
if (settingName === "feature_element_call_video_rooms") return true;
|
||||
return false;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
vm.dispose();
|
||||
});
|
||||
|
||||
describe("snapshot", () => {
|
||||
it("should compute snapshot for Home space", () => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
|
||||
const snapshot = vm.getSnapshot();
|
||||
expect(snapshot.title).toBe("Home");
|
||||
expect(snapshot.displayComposeMenu).toBe(true);
|
||||
expect(snapshot.displaySpaceMenu).toBe(false);
|
||||
expect(snapshot.canCreateRoom).toBe(true);
|
||||
expect(snapshot.canCreateVideoRoom).toBe(true);
|
||||
expect(snapshot.activeSortOption).toBe("recent");
|
||||
});
|
||||
|
||||
it("should compute snapshot for active space", () => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
|
||||
const snapshot = vm.getSnapshot();
|
||||
expect(snapshot.title).toBe(mockSpace.roomId);
|
||||
});
|
||||
|
||||
it("should hide video room option when feature is disabled", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||
if (settingName === "feature_video_rooms") return false;
|
||||
return false;
|
||||
});
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
expect(vm.getSnapshot().canCreateVideoRoom).toBe(false);
|
||||
});
|
||||
|
||||
it("should show alphabetical sort option when RoomList.preferredSorting is Alphabetic", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||
if (settingName === "RoomList.preferredSorting") return SortingAlgorithm.Alphabetic;
|
||||
return false;
|
||||
});
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
expect(vm.getSnapshot().activeSortOption).toBe("alphabetical");
|
||||
});
|
||||
|
||||
it("should hide compose menu when user cannot create rooms", () => {
|
||||
mocked(hasCreateRoomRights).mockReturnValue(false);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
|
||||
const snapshot = vm.getSnapshot();
|
||||
expect(snapshot.displayComposeMenu).toBe(false);
|
||||
expect(snapshot.canCreateRoom).toBe(false);
|
||||
});
|
||||
|
||||
it("should show invite option when space is public", () => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
|
||||
jest.spyOn(mockSpace, "getJoinRule").mockReturnValue(JoinRule.Public);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
expect(vm.getSnapshot().canInviteInSpace).toBe(true);
|
||||
});
|
||||
|
||||
it("should hide invite option when user cannot invite", () => {
|
||||
mocked(mockSpace.canInvite).mockReturnValue(false);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
expect(vm.getSnapshot().canInviteInSpace).toBe(false);
|
||||
});
|
||||
|
||||
it("should hide space settings when user cannot access them", () => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
|
||||
mocked(shouldShowSpaceSettings).mockReturnValue(false);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
expect(vm.getSnapshot().canAccessSpaceSettings).toBe(false);
|
||||
});
|
||||
|
||||
it("should show message preview when RoomList.showMessagePreview is enabled", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||
if (settingName === "RoomList.showMessagePreview") return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("event listeners", () => {
|
||||
it.each([UPDATE_SELECTED_SPACE, UPDATE_HOME_BEHAVIOUR])(
|
||||
"should update snapshot when %s event is emitted",
|
||||
(event) => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(MetaSpace.Home);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
|
||||
SpaceStore.instance.emit(event);
|
||||
|
||||
expect(vm.getSnapshot().title).toBe(mockSpace.roomId);
|
||||
},
|
||||
);
|
||||
|
||||
it("should update snapshot when space name changes", () => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
|
||||
mockSpace.name = "new name";
|
||||
mockSpace.emit(RoomEvent.Name, mockSpace);
|
||||
|
||||
expect(vm.getSnapshot().title).toBe("new name");
|
||||
});
|
||||
});
|
||||
|
||||
describe("actions", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpace", "get").mockReturnValue(mockSpace.roomId);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(mockSpace);
|
||||
});
|
||||
|
||||
it("should fire CreateChat action when createChatRoom is called", () => {
|
||||
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
|
||||
vm.createChatRoom(new Event("click"));
|
||||
expect(fireSpy).toHaveBeenCalledWith(Action.CreateChat);
|
||||
});
|
||||
|
||||
it("should call createRoom with active space when in a space", () => {
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.createRoom(new Event("click"));
|
||||
|
||||
expect(createRoom).toHaveBeenCalledWith(mockSpace);
|
||||
});
|
||||
|
||||
it("should show create video room dialog for space when createVideoRoom is called", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||
if (settingName === "feature_element_call_video_rooms") return false;
|
||||
return false;
|
||||
});
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.createVideoRoom();
|
||||
expect(showCreateNewRoom).toHaveBeenCalledWith(mockSpace, RoomType.ElementVideo);
|
||||
});
|
||||
|
||||
it("should use UnstableCall type when element_call_video_rooms is enabled", () => {
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
|
||||
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.createVideoRoom();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.CreateRoom,
|
||||
type: RoomType.UnstableCall,
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch ViewRoom action when openSpaceHome is called", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.openSpaceHome();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "!space:server",
|
||||
metricsTrigger: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("should show space invite dialog when inviteInSpace is called", () => {
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.inviteInSpace();
|
||||
|
||||
expect(showSpaceInvite).toHaveBeenCalledWith(mockSpace);
|
||||
});
|
||||
|
||||
it("should show space preferences dialog when openSpacePreferences is called", () => {
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.openSpacePreferences();
|
||||
|
||||
expect(showSpacePreferences).toHaveBeenCalledWith(mockSpace);
|
||||
});
|
||||
|
||||
it("should show space settings dialog when openSpaceSettings is called", () => {
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.openSpaceSettings();
|
||||
|
||||
expect(showSpaceSettings).toHaveBeenCalledWith(mockSpace);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["recent" as const, SortingAlgorithm.Recency],
|
||||
["alphabetical" as const, SortingAlgorithm.Alphabetic],
|
||||
["unread-first" as const, SortingAlgorithm.Unread],
|
||||
])("should resort when sort is called with '%s'", (option, expectedAlgorithm) => {
|
||||
const resortSpy = jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(jest.fn());
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
vm.sort(option);
|
||||
expect(resortSpy).toHaveBeenCalledWith(expectedAlgorithm);
|
||||
});
|
||||
|
||||
it("should track analytics on resort", () => {
|
||||
jest.spyOn(RoomListStoreV3.instance, "activeSortAlgorithm", "get").mockReturnValue(
|
||||
SortingAlgorithm.Alphabetic,
|
||||
);
|
||||
PosthogTrackers.trackRoomListSortingAlgorithmChange = jest.fn();
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
jest.spyOn(RoomListStoreV3.instance, "resort").mockImplementation(jest.fn());
|
||||
vm.sort("unread-first");
|
||||
|
||||
expect(PosthogTrackers.trackRoomListSortingAlgorithmChange).toHaveBeenCalledWith(
|
||||
SortingAlgorithm.Alphabetic,
|
||||
SortingAlgorithm.Unread,
|
||||
);
|
||||
});
|
||||
|
||||
it("should toggle message preview from enabled to disabled", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
|
||||
if (settingName === "RoomList.showMessagePreview") return true;
|
||||
return false;
|
||||
});
|
||||
const setValueSpy = jest.spyOn(SettingsStore, "setValue").mockImplementation(jest.fn());
|
||||
|
||||
vm = new RoomListHeaderViewModel({ matrixClient, spaceStore: SpaceStore.instance });
|
||||
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(true);
|
||||
|
||||
vm.toggleMessagePreview();
|
||||
|
||||
expect(setValueSpy).toHaveBeenCalledWith("RoomList.showMessagePreview", null, expect.anything(), false);
|
||||
expect(vm.getSnapshot().isMessagePreviewEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 MatrixClient, type MatrixEvent, Room, RoomEvent, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
|
||||
import { CallType } from "matrix-js-sdk/src/webrtc/call";
|
||||
|
||||
import { createTestClient, flushPromises } from "../../test-utils";
|
||||
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
|
||||
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
|
||||
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||
import { type MessagePreview, MessagePreviewStore } from "../../../src/stores/room-list/MessagePreviewStore";
|
||||
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
import { DefaultTagID } from "../../../src/stores/room-list/models";
|
||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import { CallStore } from "../../../src/stores/CallStore";
|
||||
import type { Call } from "../../../src/models/Call";
|
||||
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/stores/CallStore", () => ({
|
||||
__esModule: true,
|
||||
CallStore: {
|
||||
instance: {
|
||||
getCall: jest.fn(),
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
},
|
||||
},
|
||||
CallStoreEvent: {
|
||||
ConnectedCalls: "connected_calls",
|
||||
},
|
||||
}));
|
||||
|
||||
describe("RoomListItemViewModel", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let room: Room;
|
||||
let notificationState: RoomNotificationState;
|
||||
let viewModel: RoomListItemViewModel;
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = createTestClient();
|
||||
room = new Room("!room:server", matrixClient, matrixClient.getSafeUserId(), {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
});
|
||||
|
||||
// Set room name
|
||||
room.name = "Test Room";
|
||||
|
||||
notificationState = new RoomNotificationState(room, false);
|
||||
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
|
||||
|
||||
const dmRoomMap = {
|
||||
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as DMRoomMap;
|
||||
DMRoomMap.setShared(dmRoomMap);
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting) => {
|
||||
if (setting === "RoomList.showMessagePreview") return false;
|
||||
return false;
|
||||
});
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation(() => "watcher-id");
|
||||
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue(null);
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(null);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
viewModel?.dispose();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Initialization", () => {
|
||||
it("should initialize with room data", async () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
// Wait for async initialization
|
||||
await flushPromises();
|
||||
|
||||
const snapshot = viewModel.getSnapshot();
|
||||
expect(snapshot.id).toBe("!room:server");
|
||||
expect(snapshot.name).toBe("Test Room");
|
||||
});
|
||||
|
||||
it("should load message preview when enabled", async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
|
||||
text: "Hello world!",
|
||||
} as MessagePreview);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
// Wait for async message preview load
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().messagePreview).toBe("Hello world!");
|
||||
});
|
||||
|
||||
it("should not load message preview when disabled", async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Notification state", () => {
|
||||
it("should reflect notification state", async () => {
|
||||
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
jest.spyOn(notificationState, "count", "get").mockReturnValue(5);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
const snapshot = viewModel.getSnapshot();
|
||||
expect(snapshot.notification.hasAnyNotificationOrActivity).toBe(true);
|
||||
expect(snapshot.notification.count).toBe(5);
|
||||
});
|
||||
|
||||
it("should update when notification state changes", async () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().notification.count).toBe(0);
|
||||
|
||||
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
|
||||
notificationState.emit(NotificationStateEvents.Update);
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().notification.count).toBe(3);
|
||||
});
|
||||
|
||||
it("should show bold text when has notifications", async () => {
|
||||
jest.spyOn(notificationState, "hasAnyNotificationOrActivity", "get").mockReturnValue(true);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().isBold).toBe(true);
|
||||
});
|
||||
|
||||
it("should show mention badge", async () => {
|
||||
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().notification.isMention).toBe(true);
|
||||
});
|
||||
|
||||
it("should show invitation state", async () => {
|
||||
jest.spyOn(notificationState, "invited", "get").mockReturnValue(true);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().notification.invited).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Message preview", () => {
|
||||
it("should update message preview when store emits update", async () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
|
||||
text: "Initial message",
|
||||
} as MessagePreview);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().messagePreview).toBe("Initial message");
|
||||
|
||||
// Update preview
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
|
||||
text: "Updated message",
|
||||
} as MessagePreview);
|
||||
|
||||
MessagePreviewStore.instance.emit(UPDATE_EVENT);
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().messagePreview).toBe("Updated message");
|
||||
});
|
||||
|
||||
it("should show/hide preview when setting changes", async () => {
|
||||
let showPreview = false;
|
||||
let watchCallback: any;
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(() => showPreview);
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockImplementation((_setting, _room, callback) => {
|
||||
watchCallback = callback;
|
||||
return "watcher-id";
|
||||
});
|
||||
jest.spyOn(MessagePreviewStore.instance, "getPreviewForRoom").mockResolvedValue({
|
||||
text: "Test message",
|
||||
} as MessagePreview);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().messagePreview).toBeUndefined();
|
||||
|
||||
// Enable previews
|
||||
showPreview = true;
|
||||
watchCallback(null, "device", true);
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().messagePreview).toBe("Test message");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room tags", () => {
|
||||
it("should reflect favorite tag", async () => {
|
||||
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().isFavourite).toBe(true);
|
||||
});
|
||||
|
||||
it("should reflect low priority tag", async () => {
|
||||
room.tags = { [DefaultTagID.LowPriority]: { order: 0 } };
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().isLowPriority).toBe(true);
|
||||
});
|
||||
|
||||
it("should update when room tags change", async () => {
|
||||
room.tags = {};
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().isFavourite).toBe(false);
|
||||
|
||||
room.tags = { [DefaultTagID.Favourite]: { order: 0 } };
|
||||
const tagEvent = {
|
||||
getContent: () => ({ tags: { [DefaultTagID.Favourite]: { order: 0 } } }),
|
||||
} as MatrixEvent;
|
||||
room.emit(RoomEvent.Tags, tagEvent, room);
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().isFavourite).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Call state", () => {
|
||||
it("should show voice call indicator", async () => {
|
||||
const mockCall = {
|
||||
callType: CallType.Voice,
|
||||
participants: new Map([[matrixClient.getUserId()!, {}]]),
|
||||
} as unknown as Call;
|
||||
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().notification.callType).toBe("voice");
|
||||
});
|
||||
|
||||
it("should show video call indicator", async () => {
|
||||
const mockCall = {
|
||||
callType: CallType.Video,
|
||||
participants: new Map([[matrixClient.getUserId()!, {}]]),
|
||||
} as unknown as Call;
|
||||
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().notification.callType).toBe("video");
|
||||
});
|
||||
|
||||
it("should not show call indicator when no participants", async () => {
|
||||
const mockCall = {
|
||||
callType: CallType.Voice,
|
||||
participants: new Map(),
|
||||
} as unknown as Call;
|
||||
|
||||
jest.spyOn(CallStore.instance, "getCall").mockReturnValue(mockCall);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().notification.callType).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room name updates", () => {
|
||||
it("should update when room name changes", async () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().name).toBe("Test Room");
|
||||
|
||||
room.name = "Updated Room";
|
||||
room.emit(RoomEvent.Name, room);
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().name).toBe("Updated Room");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DM detection", () => {
|
||||
it("should detect DM rooms", async () => {
|
||||
const dmRoomMap = DMRoomMap.shared();
|
||||
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue("@user:server");
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// DM rooms should not show copy room link option
|
||||
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(false);
|
||||
});
|
||||
|
||||
it("should detect non-DM rooms", async () => {
|
||||
const dmRoomMap = DMRoomMap.shared();
|
||||
jest.spyOn(dmRoomMap, "getUserIdForRoomId").mockReturnValue(undefined);
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(viewModel.getSnapshot().canCopyRoomLink).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Actions", () => {
|
||||
it("should dispatch view room action on openRoom", () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
viewModel.onOpenRoom();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "!room:server",
|
||||
metricsTrigger: "RoomList",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return room object", () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().room).toBe(room);
|
||||
});
|
||||
|
||||
it("should dispatch view_invite action when onInvite is called", () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
viewModel.onInvite();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: "view_invite",
|
||||
roomId: "!room:server",
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch copy_room action when onCopyRoomLink is called", () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
viewModel.onCopyRoomLink();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: "copy_room",
|
||||
room_id: "!room:server",
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch leave_room action when onLeaveRoom is called for normal room", () => {
|
||||
room.tags = {};
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
viewModel.onLeaveRoom();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: "leave_room",
|
||||
room_id: "!room:server",
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch forget_room action when onLeaveRoom is called for archived room", () => {
|
||||
room.tags = { [DefaultTagID.Archived]: { order: 0 } };
|
||||
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
viewModel.onLeaveRoom();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: "forget_room",
|
||||
room_id: "!room:server",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cleanup", () => {
|
||||
it("should unsubscribe from all events on dispose", () => {
|
||||
viewModel = new RoomListItemViewModel({ room, client: matrixClient });
|
||||
|
||||
const offSpy = jest.spyOn(notificationState, "off");
|
||||
|
||||
viewModel.dispose();
|
||||
|
||||
expect(offSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 { mocked } from "jest-mock";
|
||||
|
||||
import { RoomListSearchViewModel } from "../../../src/viewmodels/room-list/RoomListSearchViewModel";
|
||||
import { MetaSpace } from "../../../src/stores/spaces";
|
||||
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../../src/LegacyCallHandler";
|
||||
|
||||
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
|
||||
shouldShowComponent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/PosthogTrackers", () => ({
|
||||
trackInteraction: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("RoomListSearchViewModel", () => {
|
||||
beforeEach(() => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("snapshot", () => {
|
||||
it("should show explore button in Home space when UIComponent.ExploreRooms is enabled", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
expect(vm.getSnapshot().displayExploreButton).toBe(true);
|
||||
});
|
||||
|
||||
it("should hide explore button when not in Home space", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.VideoRooms });
|
||||
|
||||
expect(vm.getSnapshot().displayExploreButton).toBe(false);
|
||||
});
|
||||
|
||||
it("should hide explore button when UIComponent.ExploreRooms is disabled", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(false);
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
expect(vm.getSnapshot().displayExploreButton).toBe(false);
|
||||
});
|
||||
|
||||
it("should show dial button when PSTN protocol is supported", () => {
|
||||
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
expect(vm.getSnapshot().displayDialButton).toBe(true);
|
||||
});
|
||||
|
||||
it("should hide dial button when PSTN protocol is not supported", () => {
|
||||
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
expect(vm.getSnapshot().displayDialButton).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("actions", () => {
|
||||
it("should fire OpenSpotlight action when onSearchClick is called", () => {
|
||||
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
vm.onSearchClick();
|
||||
expect(fireSpy).toHaveBeenCalledWith(Action.OpenSpotlight);
|
||||
});
|
||||
|
||||
it("should fire OpenDialPad action when onDialPadClick is called", () => {
|
||||
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
vm.onDialPadClick();
|
||||
expect(fireSpy).toHaveBeenCalledWith(Action.OpenDialPad);
|
||||
});
|
||||
|
||||
it("should fire ViewRoomDirectory action and track interaction when onExploreClick is called", () => {
|
||||
const fireSpy = jest.spyOn(defaultDispatcher, "fire");
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
const mockEvent = {} as React.MouseEvent<HTMLButtonElement>;
|
||||
vm.onExploreClick(mockEvent);
|
||||
|
||||
expect(fireSpy).toHaveBeenCalledWith(Action.ViewRoomDirectory);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update snapshot when PSTN protocol support changes", () => {
|
||||
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(false);
|
||||
const vm = new RoomListSearchViewModel({ activeSpace: MetaSpace.Home });
|
||||
|
||||
expect(vm.getSnapshot().displayDialButton).toBe(false);
|
||||
|
||||
// Simulate PSTN protocol support change
|
||||
jest.spyOn(LegacyCallHandler.instance, "getSupportsPstnProtocol").mockReturnValue(true);
|
||||
LegacyCallHandler.instance.emit(LegacyCallHandlerEvent.ProtocolSupport);
|
||||
|
||||
expect(vm.getSnapshot().displayDialButton).toBe(true);
|
||||
|
||||
vm.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,546 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
|
||||
import { createTestClient, flushPromises, mkStubRoom, stubClient } from "../../test-utils";
|
||||
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
||||
import { FilterKey } from "../../../src/stores/room-list-v3/skip-list/filters";
|
||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import { SdkContextClass } from "../../../src/contexts/SDKContext";
|
||||
import DMRoomMap from "../../../src/utils/DMRoomMap";
|
||||
import { RoomListViewViewModel } from "../../../src/viewmodels/room-list/RoomListViewViewModel";
|
||||
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
hasCreateRoomRights: jest.fn().mockReturnValue(false),
|
||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
describe("RoomListViewViewModel", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let room1: Room;
|
||||
let room2: Room;
|
||||
let room3: Room;
|
||||
let viewModel: RoomListViewViewModel;
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = createTestClient();
|
||||
room1 = mkStubRoom("!room1:server", "Room 1", matrixClient);
|
||||
room2 = mkStubRoom("!room2:server", "Room 2", matrixClient);
|
||||
room3 = mkStubRoom("!room3:server", "Room 3", matrixClient);
|
||||
|
||||
// Setup DMRoomMap
|
||||
const dmRoomMap = {
|
||||
getUserIdForRoomId: jest.fn().mockReturnValue(null),
|
||||
} as unknown as DMRoomMap;
|
||||
DMRoomMap.setShared(dmRoomMap);
|
||||
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [room1, room2, room3],
|
||||
});
|
||||
|
||||
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(false);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(null);
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
|
||||
|
||||
mocked(hasCreateRoomRights).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
viewModel?.dispose();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Initialization", () => {
|
||||
it("should initialize with correct snapshot", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const snapshot = viewModel.getSnapshot();
|
||||
expect(snapshot.roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
|
||||
expect(snapshot.isRoomListEmpty).toBe(false);
|
||||
expect(snapshot.isLoadingRooms).toBe(false);
|
||||
expect(snapshot.roomListState.spaceId).toBe("home");
|
||||
expect(snapshot.filterIds.length).toBeGreaterThan(0);
|
||||
expect(snapshot.activeFilterId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should initialize with empty room list", () => {
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [],
|
||||
});
|
||||
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().roomIds).toEqual([]);
|
||||
expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
|
||||
});
|
||||
|
||||
it("should set canCreateRoom based on user rights", () => {
|
||||
mocked(hasCreateRoomRights).mockReturnValue(true);
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().canCreateRoom).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room list updates", () => {
|
||||
it("should update room list when ListsUpdate event fires", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient);
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [room1, room2, room3, newRoom],
|
||||
});
|
||||
|
||||
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
||||
|
||||
expect(viewModel.getSnapshot().roomIds).toEqual([
|
||||
"!room1:server",
|
||||
"!room2:server",
|
||||
"!room3:server",
|
||||
"!room4:server",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should update loading state when ListsLoaded event fires", () => {
|
||||
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true);
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().isLoadingRooms).toBe(true);
|
||||
|
||||
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsLoaded);
|
||||
|
||||
expect(viewModel.getSnapshot().isLoadingRooms).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Space switching", () => {
|
||||
it("should update room list when space changes", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const spaceRoomList = [room1, room2];
|
||||
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "!space:server",
|
||||
rooms: spaceRoomList,
|
||||
});
|
||||
|
||||
jest.spyOn(SpaceStore.instance, "getLastSelectedRoomIdForSpace").mockReturnValue("!room1:server");
|
||||
|
||||
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
||||
|
||||
expect(viewModel.getSnapshot().roomListState.spaceId).toBe("!space:server");
|
||||
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server"]);
|
||||
});
|
||||
|
||||
it("should clear view models when space changes", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
// Get view models for visible rooms
|
||||
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
|
||||
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
|
||||
|
||||
const disposeSpy1 = jest.spyOn(vm1, "dispose");
|
||||
const disposeSpy2 = jest.spyOn(vm2, "dispose");
|
||||
|
||||
// Change space
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "!space:server",
|
||||
rooms: [room3],
|
||||
});
|
||||
|
||||
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
||||
|
||||
expect(disposeSpy1).toHaveBeenCalled();
|
||||
expect(disposeSpy2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Active room tracking", () => {
|
||||
it("should update active room index when room is selected", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ActiveRoomChanged,
|
||||
oldRoomId: "!room1:server",
|
||||
newRoomId: "!room2:server",
|
||||
});
|
||||
|
||||
// Use setTimeout to allow the dispatcher callback to run
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("should return undefined active room index when no room is selected", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ActiveRoomChanged,
|
||||
oldRoomId: "!room1:server",
|
||||
newRoomId: null,
|
||||
});
|
||||
|
||||
// Use setTimeout to allow the dispatcher callback to run
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sticky room behavior", () => {
|
||||
it("should keep selected room at same index when room list updates", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
// Select room at index 1
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
|
||||
dispatcher.dispatch({
|
||||
action: Action.ActiveRoomChanged,
|
||||
newRoomId: "!room2:server",
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
|
||||
|
||||
// Simulate room list update that would move room2 to front
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [room2, room1, room3], // room2 moved to front
|
||||
});
|
||||
|
||||
RoomListStoreV3.instance.emit(RoomListStoreV3Event.ListsUpdate);
|
||||
|
||||
// Active room should still be at index 1 (sticky behavior)
|
||||
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(1);
|
||||
expect(viewModel.getSnapshot().roomIds[1]).toBe("!room2:server");
|
||||
});
|
||||
|
||||
it("should not apply sticky behavior when user changes rooms", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
// Select room at index 1
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
|
||||
dispatcher.dispatch({
|
||||
action: Action.ActiveRoomChanged,
|
||||
newRoomId: "!room2:server",
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// User switches to room3
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room3:server");
|
||||
dispatcher.dispatch({
|
||||
action: Action.ActiveRoomChanged,
|
||||
oldRoomId: "!room2:server",
|
||||
newRoomId: "!room3:server",
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
expect(viewModel.getSnapshot().roomListState.activeRoomIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filters", () => {
|
||||
it("should toggle filter on", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
|
||||
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [room1],
|
||||
filterKeys: [FilterKey.UnreadFilter],
|
||||
});
|
||||
|
||||
viewModel.onToggleFilter("unread");
|
||||
|
||||
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
|
||||
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server"]);
|
||||
});
|
||||
|
||||
it("should toggle filter off", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
// Turn filter on
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [room1],
|
||||
filterKeys: [FilterKey.UnreadFilter],
|
||||
});
|
||||
viewModel.onToggleFilter("unread");
|
||||
|
||||
expect(viewModel.getSnapshot().activeFilterId).toBe("unread");
|
||||
|
||||
// Turn filter off
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [room1, room2, room3],
|
||||
});
|
||||
viewModel.onToggleFilter("unread");
|
||||
|
||||
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
|
||||
expect(viewModel.getSnapshot().roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
|
||||
});
|
||||
|
||||
it("should clear view models when filter changes", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
// Get view models
|
||||
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
|
||||
const disposeSpy = jest.spyOn(vm1, "dispose");
|
||||
|
||||
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
|
||||
spaceId: "home",
|
||||
rooms: [room2],
|
||||
filterKeys: [FilterKey.UnreadFilter],
|
||||
});
|
||||
|
||||
viewModel.onToggleFilter("unread");
|
||||
|
||||
expect(disposeSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room item view models", () => {
|
||||
it("should create room item view model on demand", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const itemViewModel = viewModel.getRoomItemViewModel("!room1:server");
|
||||
|
||||
expect(itemViewModel).toBeDefined();
|
||||
expect(itemViewModel.getSnapshot().room).toBe(room1);
|
||||
});
|
||||
|
||||
it("should reuse existing room item view model", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server");
|
||||
const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server");
|
||||
|
||||
expect(itemViewModel1).toBe(itemViewModel2);
|
||||
});
|
||||
|
||||
it("should throw error when requesting view model for non-existent room", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
expect(() => {
|
||||
viewModel.getRoomItemViewModel("!nonexistent:server");
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("should dispose view models for rooms no longer visible", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
|
||||
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
|
||||
const vm3 = viewModel.getRoomItemViewModel("!room3:server");
|
||||
|
||||
const disposeSpy1 = jest.spyOn(vm1, "dispose");
|
||||
const disposeSpy3 = jest.spyOn(vm3, "dispose");
|
||||
|
||||
// Update to show only middle room (index 1)
|
||||
viewModel.updateVisibleRooms(1, 2);
|
||||
|
||||
expect(disposeSpy1).toHaveBeenCalled();
|
||||
expect(disposeSpy3).toHaveBeenCalled();
|
||||
|
||||
// vm2 should still exist
|
||||
const vm2Again = viewModel.getRoomItemViewModel("!room2:server");
|
||||
expect(vm2Again).toBe(vm2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Room creation", () => {
|
||||
it("should dispatch CreateChat action when createChatRoom is called", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "fire");
|
||||
|
||||
viewModel.createChatRoom();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(Action.CreateChat);
|
||||
});
|
||||
|
||||
it("should dispatch CreateRoom action without parent space", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
viewModel.createRoom();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.CreateRoom,
|
||||
});
|
||||
});
|
||||
|
||||
it("should dispatch CreateRoom action with parent space", () => {
|
||||
const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient);
|
||||
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom);
|
||||
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
viewModel.createRoom();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.CreateRoom,
|
||||
parent_space: spaceRoom,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Keyboard navigation (ViewRoomDelta)", () => {
|
||||
beforeEach(() => {
|
||||
// stubClient sets up MatrixClientPeg which is needed when ViewRoom action is dispatched
|
||||
stubClient();
|
||||
});
|
||||
|
||||
it("should navigate to next room when delta is 1", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "!room2:server",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should navigate to previous room when delta is -1", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "!room1:server",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should wrap around to last room when navigating backwards from first room", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: -1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatchSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewRoom,
|
||||
room_id: "!room3:server",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not navigate when current room is not found", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server");
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
dispatchSpy.mockClear();
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
// Should not dispatch ViewRoom since current room wasn't found
|
||||
expect(dispatchSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewRoom,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("should not navigate when no room is selected", async () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
|
||||
|
||||
const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
|
||||
dispatchSpy.mockClear();
|
||||
|
||||
dispatcher.dispatch({
|
||||
action: Action.ViewRoomDelta,
|
||||
delta: 1,
|
||||
unread: false,
|
||||
});
|
||||
|
||||
await flushPromises();
|
||||
|
||||
expect(dispatchSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewRoom,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cleanup", () => {
|
||||
it("should dispose all room item view models on dispose", () => {
|
||||
viewModel = new RoomListViewViewModel({ client: matrixClient });
|
||||
|
||||
const vm1 = viewModel.getRoomItemViewModel("!room1:server");
|
||||
const vm2 = viewModel.getRoomItemViewModel("!room2:server");
|
||||
|
||||
const disposeSpy1 = jest.spyOn(vm1, "dispose");
|
||||
const disposeSpy2 = jest.spyOn(vm2, "dispose");
|
||||
|
||||
viewModel.dispose();
|
||||
|
||||
expect(disposeSpy1).toHaveBeenCalled();
|
||||
expect(disposeSpy2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector 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 { mocked } from "jest-mock";
|
||||
|
||||
import type { MatrixClient, Room, RoomState } from "matrix-js-sdk/src/matrix";
|
||||
import { createTestClient, mkStubRoom } from "../../test-utils";
|
||||
import { shouldShowComponent } from "../../../src/customisations/helpers/UIComponents";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import { showCreateNewRoom } from "../../../src/utils/space";
|
||||
import { hasCreateRoomRights, createRoom, hasAccessToNotificationMenu } from "../../../src/viewmodels/room-list/utils";
|
||||
|
||||
jest.mock("../../../src/customisations/helpers/UIComponents", () => ({
|
||||
shouldShowComponent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/space", () => ({
|
||||
showCreateNewRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("utils", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let space: Room;
|
||||
|
||||
beforeEach(() => {
|
||||
matrixClient = createTestClient();
|
||||
space = mkStubRoom("spaceId", "spaceName", matrixClient);
|
||||
});
|
||||
|
||||
describe("createRoom", () => {
|
||||
it("should fire Action.CreateRoom when createRoom is called without a space", async () => {
|
||||
const spy = jest.spyOn(defaultDispatcher, "fire");
|
||||
await createRoom();
|
||||
|
||||
expect(spy).toHaveBeenCalledWith(Action.CreateRoom);
|
||||
});
|
||||
|
||||
it("should call showCreateNewRoom when createRoom is called in a space", async () => {
|
||||
await createRoom(space);
|
||||
expect(showCreateNewRoom).toHaveBeenCalledWith(space);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasCreateRoomRights", () => {
|
||||
it("should return false when UIComponent.CreateRooms is disabled", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(false);
|
||||
expect(hasCreateRoomRights(matrixClient, space)).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when UIComponent.CreateRooms is enabled and no space", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
expect(hasCreateRoomRights(matrixClient)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false in space when UIComponent.CreateRooms is enabled and the user doesn't have the rights", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
jest.spyOn(space.getLiveTimeline(), "getState").mockReturnValue({
|
||||
maySendStateEvent: jest.fn().mockReturnValue(true),
|
||||
} as unknown as RoomState);
|
||||
|
||||
expect(hasCreateRoomRights(matrixClient)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("hasAccessToNotificationMenu", () => {
|
||||
mocked(shouldShowComponent).mockReturnValue(true);
|
||||
const room = mkStubRoom("roomId", "roomName", matrixClient);
|
||||
const isGuest = false;
|
||||
const isArchived = false;
|
||||
|
||||
expect(hasAccessToNotificationMenu(room, isGuest, isArchived)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* Copyright (c) 2025 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 {
|
||||
SyncState,
|
||||
MatrixError,
|
||||
ClientEvent,
|
||||
type MatrixClient,
|
||||
type Room,
|
||||
type MatrixEvent,
|
||||
EventStatus,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { RoomStatusBarState } from "@element-hq/web-shared-components";
|
||||
import { type MockedObject } from "jest-mock";
|
||||
|
||||
import { mkEvent, mkRoom, stubClient } from "../../test-utils";
|
||||
import { RoomStatusBarViewModel } from "../../../src/viewmodels/room/RoomStatusBar";
|
||||
import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom";
|
||||
|
||||
const userId = "@example:example.org";
|
||||
|
||||
function mkEventWithError(error: MatrixError): MatrixEvent {
|
||||
const event = mkEvent({
|
||||
event: true,
|
||||
user: userId,
|
||||
type: "org.example.test",
|
||||
content: {},
|
||||
status: EventStatus.NOT_SENT,
|
||||
});
|
||||
event.error = error;
|
||||
return event;
|
||||
}
|
||||
|
||||
describe("RoomStatusBarViewModel", () => {
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let vm: RoomStatusBarViewModel;
|
||||
let room: MockedObject<Room>;
|
||||
let roomEmitFn!: () => void;
|
||||
beforeEach(() => {
|
||||
client = stubClient() as MockedObject<MatrixClient>;
|
||||
room = mkRoom(client, "!example");
|
||||
jest.spyOn(room, "on").mockImplementationOnce((_event, fn) => {
|
||||
roomEmitFn = fn as any;
|
||||
return room;
|
||||
});
|
||||
vm = new RoomStatusBarViewModel({
|
||||
room,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should not be visible by default", () => {
|
||||
expect(vm.getSnapshot()).toEqual({ state: null });
|
||||
});
|
||||
|
||||
it("should resolve state to ConnectionLost on failed sync", () => {
|
||||
client.getSyncState.mockReturnValue(SyncState.Error);
|
||||
client.emit(ClientEvent.Sync, SyncState.Error, null);
|
||||
expect(vm.getSnapshot()).toEqual({ state: RoomStatusBarState.ConnectionLost });
|
||||
});
|
||||
|
||||
// Because we expect LoggedInView to pop a toast
|
||||
it("should resolve state to nothing if sync error is M_RESOURCE_LIMIT_EXCEEDED", () => {
|
||||
client.getSyncState.mockReturnValue(SyncState.Error);
|
||||
client.getSyncStateData.mockReturnValue({ error: new MatrixError({ errcode: "M_RESOURCE_LIMIT_EXCEEDED" }) });
|
||||
client.emit(ClientEvent.Sync, SyncState.Error, null);
|
||||
expect(vm.getSnapshot()).toEqual({ state: null });
|
||||
});
|
||||
|
||||
it("should resolve state to NeedsConsent if a pending event has a M_CONSENT_NOT_GIVEN error", () => {
|
||||
room.getPendingEvents.mockReturnValue([
|
||||
mkEventWithError(new MatrixError({ errcode: "M_CONSENT_NOT_GIVEN", consent_uri: "https://example.org" })),
|
||||
]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.NeedsConsent,
|
||||
consentUri: "https://example.org",
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to UnsentMessages once onTermsAndConditionsClicked is called", () => {
|
||||
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_CONSENT_NOT_GIVEN" }))]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.NeedsConsent,
|
||||
});
|
||||
vm.onTermsAndConditionsClicked();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to ResourceLimited if a pending event has a M_RESOURCE_LIMIT_EXCEEDED error", () => {
|
||||
room.getPendingEvents.mockReturnValue([
|
||||
mkEventWithError(
|
||||
new MatrixError({
|
||||
errcode: "M_RESOURCE_LIMIT_EXCEEDED",
|
||||
limit_type: "hs_disabled",
|
||||
admin_contact: "https://example.org",
|
||||
}),
|
||||
),
|
||||
]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.ResourceLimited,
|
||||
adminContactHref: "https://example.org",
|
||||
resourceLimit: "hs_disabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to UnsentMessages if there are any other events", () => {
|
||||
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_UNKNOWN" }))]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("should resolve state to isResending=true once onResendAllClick is called", async () => {
|
||||
room.getPendingEvents.mockReturnValue([mkEventWithError(new MatrixError({ errcode: "M_UNKNOWN" }))]);
|
||||
roomEmitFn();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: false,
|
||||
});
|
||||
const promise = vm.onResendAllClick();
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: RoomStatusBarState.UnsentMessages,
|
||||
isResending: true,
|
||||
});
|
||||
room.getPendingEvents.mockReturnValue([]);
|
||||
await promise;
|
||||
expect(client.resendEvent).toHaveBeenCalledTimes(1);
|
||||
expect(vm.getSnapshot()).toEqual({
|
||||
state: null,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Local rooms", () => {
|
||||
it("should resolve state to LocalRoomFailed if room fails to be created", () => {
|
||||
const localRoom = new LocalRoom("!example", client, userId);
|
||||
localRoom.state = LocalRoomState.ERROR;
|
||||
vm = new RoomStatusBarViewModel({
|
||||
room: localRoom,
|
||||
});
|
||||
expect(vm.getSnapshot()).toEqual({ state: RoomStatusBarState.LocalRoomFailed });
|
||||
});
|
||||
it("should resolve state to nothing for any other state for localroom", () => {
|
||||
const localRoom = new LocalRoom("!example", client, userId);
|
||||
localRoom.state = LocalRoomState.NEW;
|
||||
vm = new RoomStatusBarViewModel({
|
||||
room: localRoom,
|
||||
});
|
||||
expect(vm.getSnapshot()).toEqual({ state: null });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user