/* * 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 React from "react"; import { mocked } from "jest-mock"; import { ConnectionError, Direction } from "matrix-js-sdk/src/matrix"; import dispatcher from "../../../src/dispatcher/dispatcher"; import { Action } from "../../../src/dispatcher/actions"; import { formatFullDateNoTime } from "../../../src/DateUtils"; import Modal from "../../../src/Modal"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import SettingsStore from "../../../src/settings/SettingsStore"; import { UIFeature } from "../../../src/settings/UIFeature"; import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { DateSeparatorViewModel } from "../../../src/viewmodels/timeline/DateSeparatorViewModel"; import { flushPromisesWithFakeTimers } from "../../test-utils"; jest.mock("../../../src/settings/SettingsStore"); jest.mock("../../../src/contexts/SDKContext", () => ({ SdkContextClass: { instance: { roomViewStore: { getRoomId: jest.fn(), }, }, }, })); describe("DateSeparatorViewModel", () => { const HOUR_MS = 3600000; const DAY_MS = HOUR_MS * 24; // Friday Dec 17 2021, 9:09am const nowDate = new Date("2021-12-17T08:09:00.000Z"); const roomId = "!room:example.org"; const defaultProps = { roomId, ts: nowDate.getTime(), }; type TestCase = [string, number, string]; const testCases: TestCase[] = [ ["the exact same moment", nowDate.getTime(), "today"], ["same day as current day", nowDate.getTime() - HOUR_MS, "today"], ["day before the current day", nowDate.getTime() - HOUR_MS * 12, "yesterday"], ["2 days ago", nowDate.getTime() - DAY_MS * 2, "Wednesday"], ["144 hours ago", nowDate.getTime() - HOUR_MS * 144, "Sat, Dec 11, 2021"], [ "6 days ago, but less than 144h", new Date("Saturday Dec 11 2021 23:59:00 GMT+0100 (Central European Standard Time)").getTime(), "Saturday", ], ]; const watchCallbacks = new Map void>(); const mockTimestampToEvent = jest.fn(); const hasTestId = (node: React.ReactNode, testId: string): boolean => { if (!React.isValidElement<{ children?: React.ReactNode }>(node)) return false; const props = node.props as { "children"?: React.ReactNode; "data-testid"?: string }; if (props["data-testid"] === testId) return true; const children = React.Children.toArray(props.children); return children.some((child) => hasTestId(child, testId)); }; const createViewModel = ( props: Partial & { forExport?: boolean } = {}, ): DateSeparatorViewModel => { return new DateSeparatorViewModel({ ...defaultProps, ...props }); }; beforeEach(() => { jest.useFakeTimers(); jest.setSystemTime(nowDate.getTime()); watchCallbacks.clear(); mocked(SettingsStore).getValue.mockImplementation((key): any => { if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; if (key === "feature_jump_to_date") return false; return undefined; }); mocked(SettingsStore).watchSetting.mockImplementation((settingName, _roomId, cb): any => { watchCallbacks.set(String(settingName), cb); return `${String(settingName)}-watch-ref`; }); mocked(SettingsStore).unwatchSetting.mockImplementation(() => {}); mockTimestampToEvent.mockReset(); jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue({ timestampToEvent: mockTimestampToEvent, } as any); jest.spyOn(dispatcher, "dispatch").mockImplementation(() => {}); jest.spyOn(Modal, "createDialog").mockImplementation(() => ({ close: jest.fn() }) as any); mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue(roomId); }); afterEach(() => { jest.restoreAllMocks(); jest.useRealTimers(); }); it("computes relative label for today", () => { const vm = createViewModel(); expect(vm.getSnapshot().label).toBe("today"); }); it("uses full date when exporting", () => { const vm = createViewModel({ forExport: true }); expect(vm.getSnapshot().label).toBe(formatFullDateNoTime(nowDate)); }); it("updates label when relative dates setting changes at runtime", () => { const vm = createViewModel(); expect(vm.getSnapshot().label).toBe("today"); const callback = watchCallbacks.get(UIFeature.TimelineEnableRelativeDates); expect(callback).toBeDefined(); callback?.(UIFeature.TimelineEnableRelativeDates, null, null, null, false); expect(vm.getSnapshot().label).toBe(formatFullDateNoTime(nowDate)); }); it("exposes jumpToDateMenu when feature is enabled", () => { mocked(SettingsStore).getValue.mockImplementation((key): any => { if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; if (key === "feature_jump_to_date") return true; return undefined; }); const vm = createViewModel(); expect(vm.getSnapshot().jumpToEnabled).toBeTruthy(); }); it("exposes jumpFromDate in snapshot", () => { const vm = createViewModel(); expect(vm.getSnapshot().jumpFromDate).toBe("2021-12-17"); }); it("does not expose jumpToDateMenu when exporting", () => { mocked(SettingsStore).getValue.mockImplementation((key): any => { if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; if (key === "feature_jump_to_date") return true; return undefined; }); const vm = createViewModel({ forExport: true }); expect(vm.getSnapshot().jumpToEnabled).toBeFalsy(); }); it("updates jumpToEnabled when feature_jump_to_date changes at runtime", () => { const vm = createViewModel(); expect(vm.getSnapshot().jumpToEnabled).toBeFalsy(); const callback = watchCallbacks.get("feature_jump_to_date"); expect(callback).toBeDefined(); callback?.("feature_jump_to_date", null, null, null, true); expect(vm.getSnapshot().jumpToEnabled).toBeTruthy(); }); it("dispatches ViewRoom when pickDate resolves in active room", async () => { const eventId = "$event"; const unixTimestamp = nowDate.getTime() - DAY_MS; mockTimestampToEvent.mockResolvedValue({ event_id: eventId, origin_server_ts: unixTimestamp, }); const vm = createViewModel(); await vm.pickDate(unixTimestamp); expect(mockTimestampToEvent).toHaveBeenCalledWith(roomId, unixTimestamp, Direction.Forward); expect(dispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ViewRoom, event_id: eventId, highlighted: true, room_id: roomId, metricsTrigger: undefined, }); }); it("does not dispatch ViewRoom when room changed before pickDate resolves", async () => { mockTimestampToEvent.mockResolvedValue({ event_id: "$event", origin_server_ts: nowDate.getTime(), }); mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!other:example.org"); const vm = createViewModel(); await vm.pickDate(nowDate.getTime() - HOUR_MS); expect(dispatcher.dispatch).not.toHaveBeenCalled(); }); it("shows submit debug logs option for generic errors", async () => { mockTimestampToEvent.mockRejectedValue(new Error("Boom")); const vm = createViewModel(); await vm.pickDate(nowDate.getTime() - HOUR_MS); expect(Modal.createDialog).toHaveBeenCalled(); const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(true); }); it("does not show submit debug logs option for connection errors", async () => { mockTimestampToEvent.mockRejectedValue(new ConnectionError("offline")); const vm = createViewModel(); await vm.pickDate(nowDate.getTime() - HOUR_MS); expect(Modal.createDialog).toHaveBeenCalled(); const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(false); }); describe("snapshot labels", () => { it.each(testCases)("formats date correctly when current time is %s", (_d, ts, result) => { expect(createViewModel({ ts }).getSnapshot().label).toContain(result); }); describe("when forExport is true", () => { it.each(testCases)("formats date in full when current time is %s", (_d, ts) => { expect(createViewModel({ ts, forExport: true }).getSnapshot().label).toContain( formatFullDateNoTime(new Date(ts)), ); }); }); describe("when TimelineEnableRelativeDates is false", () => { beforeEach(() => { mocked(SettingsStore).getValue.mockImplementation((key): any => { if (String(key) === UIFeature.TimelineEnableRelativeDates) return false; if (key === "feature_jump_to_date") return false; return undefined; }); }); it.each(testCases)("formats date in full when current time is %s", (_d, ts) => { expect(createViewModel({ ts }).getSnapshot().label).toContain(formatFullDateNoTime(new Date(ts))); }); }); }); describe("jump actions", () => { beforeEach(() => { mocked(SettingsStore).getValue.mockImplementation((key): any => { if (String(key) === UIFeature.TimelineEnableRelativeDates) return true; if (key === "feature_jump_to_date") return true; return undefined; }); }); [ { timeDescriptor: "last week", run: (vm: DateSeparatorViewModel): Promise => vm.onLastWeekPicked(), }, { timeDescriptor: "last month", run: (vm: DateSeparatorViewModel): Promise => vm.onLastMonthPicked(), }, { timeDescriptor: "the beginning", run: (vm: DateSeparatorViewModel): Promise => vm.onBeginningPicked(), }, ].forEach((testCase) => { it(`can jump to ${testCase.timeDescriptor}`, async () => { const returnedDate = new Date(); returnedDate.setDate(nowDate.getDate() - 100); const returnedEventId = "$abc"; mockTimestampToEvent.mockResolvedValue({ event_id: returnedEventId, origin_server_ts: returnedDate.getTime(), }); const vm = createViewModel(); await testCase.run(vm); await flushPromisesWithFakeTimers(); expect(mockTimestampToEvent).toHaveBeenCalledWith(roomId, expect.any(Number), Direction.Forward); expect(dispatcher.dispatch).toHaveBeenCalledWith({ action: Action.ViewRoom, event_id: returnedEventId, highlighted: true, room_id: roomId, metricsTrigger: undefined, }); }); }); it("does not jump when room changed before request resolves", async () => { mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!some-other-room"); mockTimestampToEvent.mockResolvedValue({ event_id: "$abc", origin_server_ts: 0, }); const vm = createViewModel(); await vm.onLastWeekPicked(); await flushPromisesWithFakeTimers(); expect(dispatcher.dispatch).not.toHaveBeenCalled(); }); it("does not show jump to date error if user switched room", async () => { mocked(SdkContextClass.instance.roomViewStore.getRoomId).mockReturnValue("!some-other-room"); mockTimestampToEvent.mockRejectedValue(new Error("Fake error in test")); const vm = createViewModel(); await vm.onLastWeekPicked(); await flushPromisesWithFakeTimers(); expect(Modal.createDialog).not.toHaveBeenCalled(); }); it("shows error dialog with submit debug logs option when non-networking error occurs", async () => { mockTimestampToEvent.mockRejectedValue(new Error("Fake error in test")); const vm = createViewModel(); await vm.onLastWeekPicked(); await flushPromisesWithFakeTimers(); expect(Modal.createDialog).toHaveBeenCalled(); const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(true); }); it("shows error dialog without submit debug logs option when networking error occurs", async () => { mockTimestampToEvent.mockRejectedValue(new ConnectionError("Fake connection error in test")); const vm = createViewModel(); await vm.onLastWeekPicked(); await flushPromisesWithFakeTimers(); expect(Modal.createDialog).toHaveBeenCalled(); const [, params] = mocked(Modal.createDialog).mock.calls.at(-1)!; expect(hasTestId((params as any).description, "jump-to-date-error-submit-debug-logs-button")).toBe(false); }); }); });