diff --git a/apps/web/jest.config.ts b/apps/web/jest.config.ts index dd37e40b0f..2c302984af 100644 --- a/apps/web/jest.config.ts +++ b/apps/web/jest.config.ts @@ -62,6 +62,8 @@ const config: Config = { // Ignore vitest tests "!/src/**/*.test.{ts,tsx}", "!/src/test/**", + // Exclude mocks + "!/src/**/*-{mock,mocks}.{ts,tsx}", ], coverageReporters: ["text-summary", ["lcov", { projectRoot: "../../" }]], prettierPath: null, diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index 7c53a97736..c9d9a3c4b0 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -18,13 +18,12 @@ import { M_POLL_START, } from "matrix-js-sdk/src/matrix"; import { - CallDeclinedTileView, - CallStartedTileView, EncryptionEventView, HiddenBodyView, MJitsiWidgetEventView, MKeyVerificationRequestView, RoomAvatarEventView, + RootCallTileView, TextualEventView, ViewSourceEventView, useCreateAutoDisposedViewModel, @@ -57,7 +56,7 @@ import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/Te import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel"; import { ViewSourceEventViewModel } from "../viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel"; import { ElementCallEventType } from "../call-types"; -import { CallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallTileViewModel"; +import { RootCallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/RootCallTileViewModel"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps extends Pick< @@ -189,8 +188,16 @@ function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { const RoomAvatarEventFactory: Factory = (ref, props) => ; function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new CallTileViewModel({ mxEvent, getRelationsForEvent })); - return vm.isCallDeclined ? : ; + const cli = useMatrixClientContext(); + const vm = useCreateAutoDisposedViewModel( + () => + new RootCallTileViewModel({ + mxEvent, + getRelationsForEvent, + cli, + }), + ); + return ; } export const CallStartedEventFactory: Factory = (ref, props) => { diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/CallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/CallTileViewModel.ts deleted file mode 100644 index cfae52080b..0000000000 --- a/apps/web/src/viewmodels/room/timeline/event-tile/call/CallTileViewModel.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { BaseViewModel, CallType, type CallTileViewSnapshot } from "@element-hq/web-shared-components"; -import { EventType, type MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix"; - -import type { IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; -import SettingsStore from "../../../../../settings/SettingsStore"; -import { formatTime } from "../../../../../DateUtils"; -import defaultDispatcher from "../../../../../dispatcher/dispatcher"; -import type { SettingUpdatedPayload } from "../../../../../dispatcher/payloads/SettingUpdatedPayload"; -import type { ActionPayload } from "../../../../../dispatcher/payloads"; -import { Action } from "../../../../../dispatcher/actions"; -import type { GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile"; -import { MatrixClientPeg } from "../../../../../MatrixClientPeg"; - -export interface CallTileViewModelProps { - /** - * Event of type `org.matrix.msc4075.rtc.notification`. - */ - mxEvent: MatrixEvent; - /** - * Helper to fetch related events from a given event. - */ - getRelationsForEvent?: GetRelationsForEvent; -} - -function getIntentFromEvent(event: MatrixEvent): CallTileViewSnapshot["type"] { - const content = event.getContent(); - const intentInContent = content["m.call.intent"]; - switch (intentInContent) { - case "audio": - return CallType.Voice; - case "video": - default: - return CallType.Video; - } -} - -function getTs(event: MatrixEvent): number { - if (event.getType() === EventType.RTCNotification) { - /** - * According to the spec: - * Receivers SHOULD use origin_server_ts if |sender_ts - origin_server_ts| > 20000 ms. - */ - const content = event.getContent(); - const senderTs = content["sender_ts"]; - const originServerTs = event.getTs(); - const ts = Math.abs(senderTs - originServerTs) > 20000 ? originServerTs : senderTs; - return ts; - } else return event.getTs(); -} - -function getTimeFromEvent(event: MatrixEvent, showTwelveHour: boolean): CallTileViewSnapshot["timestamp"] { - const ts = getTs(event); - const date = new Date(ts); - const timestamp = formatTime(date, showTwelveHour); - return timestamp; -} - -function generateSnapshot( - event: MatrixEvent, - getRelationsForEvent?: GetRelationsForEvent, -): { snapshot: CallTileViewSnapshot; declineEvent: MatrixEvent | null } { - const type = getIntentFromEvent(event); - const declineEvent = getDeclinedEvent(event, getRelationsForEvent); - let isCallDeclinedByUs: boolean | undefined; - if (declineEvent) { - isCallDeclinedByUs = declineEvent.getSender() === MatrixClientPeg.get()?.getUserId(); - } - const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); - const timestamp = getTimeFromEvent(declineEvent ?? event, showTwelveHour); - return { snapshot: { type, timestamp, isCallDeclinedByUs }, declineEvent }; -} - -function isSettingsChangedPayload(payload: ActionPayload): payload is SettingUpdatedPayload { - return payload.action === Action.SettingUpdated; -} - -/** - * Get a declined event that is related to the given rtc notification event. - * @param event rtc notification event - */ -function getDeclinedEvent(event: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): MatrixEvent | null { - const eventId = event.getId(); - if (eventId && getRelationsForEvent) { - const relations = getRelationsForEvent(eventId, RelationType.Reference, EventType.RTCDecline)?.getRelations(); - if (relations) return relations[0]; - } - return null; -} - -/** - * Common view-model for call tiles; currently used to render: - * 1. A tile that indicates that a call occurred (call tombstone). - * 2. A tile that indicates that a call was declined. - */ -export class CallTileViewModel extends BaseViewModel { - /** - * The decline event associated with this call, if any. - */ - private declineEvent: MatrixEvent | null; - - public constructor(props: CallTileViewModelProps) { - const { declineEvent, snapshot } = generateSnapshot(props.mxEvent, props.getRelationsForEvent); - super(props, snapshot); - this.declineEvent = declineEvent; - - // Listen to the changes on settings so that we can update the timestamp format (12H vs 24H). - SettingsStore.monitorSetting("showTwelveHourTimestamps", null); - const token = defaultDispatcher.register(this.onAction); - this.disposables.track(() => { - defaultDispatcher.unregister(token); - }); - - // When a relation is added to the event, recompute the state. - this.disposables.trackListener(props.mxEvent, MatrixEventEvent.RelationsCreated, () => { - const { declineEvent, snapshot } = generateSnapshot(props.mxEvent, props.getRelationsForEvent); - this.declineEvent = declineEvent; - this.snapshot.set(snapshot); - }); - } - - private onAction = (payload: ActionPayload): void => { - if (!isSettingsChangedPayload(payload) || payload.settingName !== "showTwelveHourTimestamps") return; - const showTwelveHour = (payload.newValue as boolean) ?? false; - const timestamp = getTimeFromEvent(this.declineEvent ?? this.props.mxEvent, showTwelveHour); - this.snapshot.merge({ timestamp }); - }; - - /** - * Whether the call associated with this vm has been declined. - */ - public get isCallDeclined(): boolean { - return !!this.declineEvent; - } -} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.test.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.test.tsx new file mode 100644 index 0000000000..3f2004ae8e --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.test.tsx @@ -0,0 +1,100 @@ +/* + * 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. + */ + +// @vitest-environment happy-dom + +import { it, describe, expect, vi } from "vitest"; +import { type EventTimeline, EventType, type MatrixEvent, type RoomState } from "matrix-js-sdk/src/matrix"; + +import { mkEvent, mkMessage, mkRoomMember, mkStubRoom, stubClient } from "../../../../../../test/test-utils"; +import { getMockedRtcNotificationEvent, MockedCall, MockedCallStore } from "./call-mocks"; +import { RootCallTileViewModel } from "./RootCallTileViewModel"; + +function getEvents(): MatrixEvent[] { + const message1 = mkMessage({ + room: "!my-room:m.org", + user: "@alice:m.org", + event: true, + msg: "hello", + }); + + const message2 = mkMessage({ + room: "!my-room:m.org", + user: "@bob:m.org", + event: true, + msg: "hello", + }); + + const oldRtcNotificationEvent = mkEvent({ + type: EventType.RTCNotification, + id: "old-event", + content: { + "m.call.intent": "video", + }, + user: "@alice:m.org", + event: true, + }); + + const latestRtcNotificationEvent = mkEvent({ + type: EventType.RTCNotification, + id: "new-event", + content: { + "m.call.intent": "video", + }, + user: "@bob:m.org", + event: true, + }); + return [message1, message2, oldRtcNotificationEvent, latestRtcNotificationEvent]; +} + +function getMocked(userIds: string[]) { + const cli = stubClient(); + const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100); + mxEvent.getId = () => "new-event"; + + const call = MockedCall.create(); + const callStore = MockedCallStore.create(call); + + const members = userIds.map((id) => mkRoomMember("!my-room:m.org", id)); + + const room = mkStubRoom("!my-room:m.org", "My Room"); + cli.getRoom = () => room; + + vi.spyOn(room, "getMembers").mockReturnValue(members); + + const events = getEvents(); + vi.spyOn(room, "getLiveTimeline").mockImplementation(() => { + return { + getEvents: () => { + return events; + }, + getState: (): RoomState => { + return { + mayClientSendStateEvent: () => true, + } as unknown as RoomState; + }, + } as unknown as EventTimeline; + }); + + return { callStore, cli, mxEvent, call }; +} + +describe("RootCallTileViewModel", () => { + it("computes correct tileType for tombstone call in DM", () => { + const { cli, mxEvent } = getMocked(["@alice:m.org", "@bob:m.org"]); + const vm = new RootCallTileViewModel({ cli, mxEvent }); + + expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-dm"); + }); + + it("computes correct tileType for tombstone call in Room", () => { + const { cli, mxEvent } = getMocked(["@alice:m.org", "@bob:m.org", "@jack:m.org"]); + const vm = new RootCallTileViewModel({ cli, mxEvent }); + + expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-room"); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.ts new file mode 100644 index 0000000000..bbc3cdeab3 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.ts @@ -0,0 +1,69 @@ +/* + * 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 } from "matrix-js-sdk/src/matrix"; +import { BaseViewModel, type RootCallTileViewSnapshot } from "@element-hq/web-shared-components"; + +import type { GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile"; +import { DmTombstoneCallTileViewModel } from "./tiles/tombstone/DmTombstoneCallTileViewModel"; +import { RoomTombstoneCallTileViewModel } from "./tiles/tombstone/RoomTombstoneCallTileViewModel"; + +interface Props { + /** + * Event of type `org.matrix.msc4075.rtc.notification`. + */ + mxEvent: MatrixEvent; + + /** + * Helper to fetch related events from a given event. + */ + getRelationsForEvent?: GetRelationsForEvent; + + /** + * The {@link MatrixClient} object to access js-sdk API. + */ + cli: MatrixClient; +} + +function computeSnapshot(props: Props): RootCallTileViewSnapshot { + const cli = props.cli; + const notificationEvent = props.mxEvent; + + // Get the room where this call is taking place + const roomId = props.mxEvent.getRoomId(); + if (!roomId) throw new Error("Notification event does not have associated room-id"); + const room = cli.getRoom(roomId); + if (!room) throw new Error(`No room with id ${roomId}`); + + // This is the same logic used for hiding/showing the voice call button. + const isDmRoom = room.getMembers().length <= 2; + + if (isDmRoom) { + return { + tileType: "tombstone-call-dm", + tileViewModel: new DmTombstoneCallTileViewModel({ + mxEvent: notificationEvent, + getRelationsForEvent: props.getRelationsForEvent, + cli: props.cli, + }), + }; + } else + return { + tileType: "tombstone-call-room", + tileViewModel: new RoomTombstoneCallTileViewModel({ mxEvent: notificationEvent }), + }; +} + +/** + * The root call tile view model which decides what call tile should be rendered. + */ +export class RootCallTileViewModel extends BaseViewModel { + public constructor(props: Props) { + const snapshot = computeSnapshot(props); + super(props, snapshot); + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/call-mocks.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/call-mocks.ts new file mode 100644 index 0000000000..9a4a61b1db --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/call-mocks.ts @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { EventEmitter } from "events"; +import { type RoomMember, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; + +import { mkEvent } from "../../../../../../test/test-utils"; +import { type ElementCall } from "../../../../../models/Call"; +import type { CallStore } from "../../../../../stores/CallStore"; +import type { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc"; + +export function getMockedRtcNotificationEvent( + intent: string, + senderTs: number, + serverTs: number, + sender: string = "@foo:m.org", +): MatrixEvent { + const mockEvent = mkEvent({ + type: "org.matrix.msc4075.rtc.notification", + user: sender, + content: { + "m.call.intent": intent, + "sender_ts": senderTs, + }, + ts: serverTs, + event: true, + room: "!my-room:m.org", + }); + return mockEvent; +} + +export function getMockedRtcDeclineEvent(rtcNotificationEvent: MatrixEvent, sender = "@foo:m.org"): MatrixEvent { + const mockEvent = mkEvent({ + type: EventType.RTCDecline, + user: sender, + content: { + "m.relates_to": { + rel_type: "m.reference", + event_id: rtcNotificationEvent.getId(), + }, + }, + ts: 924285416000, + event: true, + }); + return mockEvent; +} + +interface MockCallStoreType extends CallStore { + withActiveCall(): this; + isActiveCall: boolean; + call: ElementCall | null; +} + +export class MockedCallStore extends EventEmitter { + public isActiveCall: boolean = false; + + public constructor(public call: ElementCall | null) { + super(); + } + + public static create(call: ElementCall | null): MockCallStoreType { + return new MockedCallStore(call) as unknown as MockCallStoreType; + } + + public withActiveCall(): this { + this.isActiveCall = true; + return this; + } + + public getCall(): ElementCall | null { + return this.call; + } + + public getActiveCall(): ElementCall | null { + if (this.isActiveCall) return this.getCall(); + return null; + } +} + +interface MockCallType extends ElementCall { + withOldestMembershipTs(ts: number): this; + withParticipants(participants: RoomMember[]): this; +} + +export class MockedCall extends EventEmitter { + public participantMap = new Map>(); + public createdTs: number = Date.now(); + + public static create(): MockCallType { + return new MockedCall() as unknown as MockCallType; + } + + public withOldestMembershipTs(ts: number): this { + this.createdTs = ts; + return this; + } + + public withParticipants(participants: RoomMember[]): this { + for (const participant of participants) { + this.participantMap.set(participant, new Set()); + } + return this; + } + + public get participants(): Map> { + return this.participantMap; + } + + public get session(): MatrixRTCSession { + return { + getOldestMembership: (): CallMembership => { + return { + createdTs: () => this.createdTs, + } as CallMembership; + }, + } as MatrixRTCSession; + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/common.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/common.ts new file mode 100644 index 0000000000..ce2e390d9c --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/common.ts @@ -0,0 +1,44 @@ +/* + * 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 { CallType } from "@element-hq/web-shared-components"; +import { EventType, RelationType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; + +import { type GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile"; + +/** + * Find the call intent from a given rtc notification event. + * @param event Rtc notification event + */ +export function getIntentFromEvent(event: MatrixEvent): CallType { + const content = event.getContent(); + const intentInContent = content["m.call.intent"]; + switch (intentInContent) { + case "audio": + return CallType.Voice; + case "video": + default: + return CallType.Video; + } +} + +/** + * Get all declined events that is related to the given rtc notification event. + * @param event rtc notification event + */ +export function getDeclinedEvents( + event: MatrixEvent, + getRelationsForEvent?: GetRelationsForEvent, +): MatrixEvent[] | null { + const eventId = event.getId(); + if (eventId && getRelationsForEvent) { + const relations = getRelationsForEvent(eventId, RelationType.Reference, EventType.RTCDecline)?.getRelations(); + if (relations) return relations; + } + return null; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/DmTombstoneCallTileViewModel.test.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/DmTombstoneCallTileViewModel.test.tsx new file mode 100644 index 0000000000..b6dde28979 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/DmTombstoneCallTileViewModel.test.tsx @@ -0,0 +1,80 @@ +/* + * 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. + */ + +// @vitest-environment happy-dom + +import { it, describe, expect, vi } from "vitest"; +import { CallDirection, CallType } from "@element-hq/web-shared-components"; +import { EventType, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; + +import { getMockedRtcDeclineEvent, getMockedRtcNotificationEvent } from "../../call-mocks"; +import { formatTime } from "../../../../../../../DateUtils"; +import { DmTombstoneCallTileViewModel } from "./DmTombstoneCallTileViewModel"; +import { stubClient } from "../../../../../../../../test/test-utils"; + +describe("DmTombstoneCallTileViewModel", () => { + it("should compute correct state on decline", () => { + const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000, "@alice:m.org"); + const declineEvent = getMockedRtcDeclineEvent(mxEvent, "@alice:m.org"); + const getRelationsForEvent = vi.fn(); + const cli = stubClient(); + const vm = new DmTombstoneCallTileViewModel({ mxEvent, getRelationsForEvent, cli }); + + // Without decline event, isCallDeclined = false + expect(vm.getSnapshot().isCallDeclined).toStrictEqual(false); + + // Decline event comes through + getRelationsForEvent.mockReturnValue({ + getRelations: () => [declineEvent], + }); + mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", EventType.RTCDecline); + + // Timestamp should be that of the decline event + expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285416000))); + // Call should be declined + expect(vm.getSnapshot().isCallDeclined).toStrictEqual(true); + }); + + it("should compute voice intent in state", () => { + const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365); + + const getRelationsForEvent = vi.fn(); + const cli = stubClient(); + const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent }); + const { type } = vm.getSnapshot(); + expect(type).toStrictEqual(CallType.Voice); + }); + + it("should compute video intent in state", () => { + const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365); + const getRelationsForEvent = vi.fn(); + const cli = stubClient(); + const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent }); + const { type } = vm.getSnapshot(); + expect(type).toStrictEqual(CallType.Video); + }); + + describe("should compute callDirection", () => { + it("for outgoing", () => { + const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365, "@alice:m.org"); + const getRelationsForEvent = vi.fn(); + const cli = stubClient(); + vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org"); + const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent }); + expect(vm.getSnapshot().callDirection).toStrictEqual(CallDirection.Outgoing); + }); + + it("for incoming", () => { + const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365, "@bob:m.org"); + const getRelationsForEvent = vi.fn(); + const cli = stubClient(); + vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org"); + const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent }); + expect(vm.getSnapshot().callDirection).toStrictEqual(CallDirection.Incoming); + }); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/DmTombstoneCallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/DmTombstoneCallTileViewModel.ts new file mode 100644 index 0000000000..1f331b6f51 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/DmTombstoneCallTileViewModel.ts @@ -0,0 +1,79 @@ +/* + * 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 { CallDirection, type DmTombstoneCallTileViewSnapshot } from "@element-hq/web-shared-components"; +import { type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix"; + +import SettingsStore from "../../../../../../../settings/SettingsStore"; +import type { GetRelationsForEvent } from "../../../../../../../components/views/rooms/EventTile"; +import { getTimeFromEvent } from "./common"; +import { + RoomTombstoneCallTileViewModel, + type RoomTombstoneCallTileViewModelProps, +} from "./RoomTombstoneCallTileViewModel"; +import { getDeclinedEvents, getIntentFromEvent } from "../../common"; + +export interface DmTombstoneCallTileViewModelProps extends RoomTombstoneCallTileViewModelProps { + /** + * Helper to fetch related events from a given event. + */ + getRelationsForEvent?: GetRelationsForEvent; + /** + * The {@link MatrixClient} object to access js-sdk API. + */ + cli: MatrixClient; +} + +function generateSnapshot(props: DmTombstoneCallTileViewModelProps): { + snapshot: DmTombstoneCallTileViewSnapshot; + declineEvent: MatrixEvent | null; +} { + const { mxEvent, getRelationsForEvent, cli } = props; + const type = getIntentFromEvent(mxEvent); + + // Find the mx-id of the user who started this call + const startedUserId = mxEvent.getSender(); + if (!startedUserId) { + throw new Error("RTCNotification event has no sender associated with it!"); + } + const callDirection = cli.getUserId() === startedUserId ? CallDirection.Outgoing : CallDirection.Incoming; + + const declineEvent = getDeclinedEvents(mxEvent, getRelationsForEvent)?.[0] ?? null; + const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); + const timestamp = getTimeFromEvent(declineEvent ?? mxEvent, showTwelveHour); + return { snapshot: { timestamp, type, callDirection, isCallDeclined: !!declineEvent }, declineEvent }; +} + +/** + * View model for a tombstone call in a DM. + */ +export class DmTombstoneCallTileViewModel extends RoomTombstoneCallTileViewModel< + DmTombstoneCallTileViewSnapshot, + DmTombstoneCallTileViewModelProps +> { + /** + * The decline event associated with this call, if any. + */ + private declineEvent: MatrixEvent | null; + + public constructor(props: DmTombstoneCallTileViewModelProps) { + const { snapshot, declineEvent } = generateSnapshot(props); + super(props, snapshot); + this.declineEvent = declineEvent; + + // When a relation is added to the event, recompute the state. + this.disposables.trackListener(props.mxEvent, MatrixEventEvent.RelationsCreated, () => { + const { declineEvent, snapshot } = generateSnapshot(props); + this.declineEvent = declineEvent; + this.snapshot.set(snapshot); + }); + } + + protected getTimestamp(showTwelveHour: boolean): string { + return getTimeFromEvent(this.declineEvent ?? this.props.mxEvent, showTwelveHour); + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/RoomTombstoneCallTileViewModel.test.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/RoomTombstoneCallTileViewModel.test.ts new file mode 100644 index 0000000000..2271294400 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/RoomTombstoneCallTileViewModel.test.ts @@ -0,0 +1,46 @@ +/* + * 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. + */ + +// @vitest-environment happy-dom + +import { it, describe, expect, vi } from "vitest"; + +import { getMockedRtcNotificationEvent } from "../../call-mocks"; +import { RoomTombstoneCallTileViewModel } from "./RoomTombstoneCallTileViewModel"; +import SettingsStore from "../../../../../../../settings/SettingsStore"; +import { SettingLevel } from "../../../../../../../settings/SettingLevel"; +import { formatTime } from "../../../../../../../DateUtils"; + +describe("RoomTombstoneCallTileViewModel", () => { + it("should compute timestamp correctly", () => { + const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000); + const vm = new RoomTombstoneCallTileViewModel({ mxEvent }); + expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000))); + }); + + it("should calculate time string correctly when configured to use 12 hour format", async () => { + const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000); + await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true); + const vm = new RoomTombstoneCallTileViewModel({ mxEvent }); + const { timestamp } = vm.getSnapshot(); + + expect(timestamp).toStrictEqual(formatTime(new Date(924285348000), true)); + SettingsStore.reset(); + }); + + it("should change timestamp format when setting is modified", async () => { + await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, false); + const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000); + const vm = new RoomTombstoneCallTileViewModel({ mxEvent }); + expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000))); + await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true); + await vi.waitFor(() => { + expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000), true)); + }); + SettingsStore.reset(); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/RoomTombstoneCallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/RoomTombstoneCallTileViewModel.ts new file mode 100644 index 0000000000..eda4a3b9a8 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/RoomTombstoneCallTileViewModel.ts @@ -0,0 +1,69 @@ +/* + * 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 { BaseViewModel, type RoomTombstoneCallTileViewSnapshot } from "@element-hq/web-shared-components"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import SettingsStore from "../../../../../../../settings/SettingsStore"; +import defaultDispatcher from "../../../../../../../dispatcher/dispatcher"; +import type { SettingUpdatedPayload } from "../../../../../../../dispatcher/payloads/SettingUpdatedPayload"; +import type { ActionPayload } from "../../../../../../../dispatcher/payloads"; +import { Action } from "../../../../../../../dispatcher/actions"; +import type { GetRelationsForEvent } from "../../../../../../../components/views/rooms/EventTile"; +import { getTimeFromEvent } from "./common"; + +export interface RoomTombstoneCallTileViewModelProps { + /** + * Event of type `org.matrix.msc4075.rtc.notification`. + */ + mxEvent: MatrixEvent; + /** + * Helper to fetch related events from a given event. + */ + getRelationsForEvent?: GetRelationsForEvent; +} + +function generateSnapshot(event: MatrixEvent): RoomTombstoneCallTileViewSnapshot { + const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); + const timestamp = getTimeFromEvent(event, showTwelveHour); + return { timestamp }; +} + +function isSettingsChangedPayload(payload: ActionPayload): payload is SettingUpdatedPayload { + return payload.action === Action.SettingUpdated; +} + +/** + * View model for a tombstone call in a room. + */ +export class RoomTombstoneCallTileViewModel< + T extends RoomTombstoneCallTileViewSnapshot = RoomTombstoneCallTileViewSnapshot, + P extends RoomTombstoneCallTileViewModelProps = RoomTombstoneCallTileViewModelProps, +> extends BaseViewModel { + public constructor(props: P, extraSnapshot: Partial = {}) { + const snapshot = { ...generateSnapshot(props.mxEvent), ...extraSnapshot }; + super(props, snapshot as T); + + // Listen to the changes on settings so that we can update the timestamp format (12H vs 24H). + SettingsStore.monitorSetting("showTwelveHourTimestamps", null); + const token = defaultDispatcher.register(this.onAction); + this.disposables.track(() => { + defaultDispatcher.unregister(token); + }); + } + + private onAction = (payload: ActionPayload): void => { + if (!isSettingsChangedPayload(payload) || payload.settingName !== "showTwelveHourTimestamps") return; + const showTwelveHour = (payload.newValue as boolean) ?? false; + const timestamp = this.getTimestamp(showTwelveHour); + this.snapshot.merge({ timestamp } as Partial); + }; + + protected getTimestamp(showTwelveHour: boolean): string { + return getTimeFromEvent(this.props.mxEvent, showTwelveHour); + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/common.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/common.ts new file mode 100644 index 0000000000..8ab63b6132 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/tombstone/common.ts @@ -0,0 +1,37 @@ +/* + * 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 { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc"; + +import { formatTime } from "../../../../../../../DateUtils"; + +function getTs(event: MatrixEvent): number { + if (event.getType() === EventType.RTCNotification) { + /** + * According to the spec: + * Receivers SHOULD use origin_server_ts if |sender_ts - origin_server_ts| > 20000 ms. + */ + const content = event.getContent(); + const senderTs = content["sender_ts"]; + const originServerTs = event.getTs(); + const ts = Math.abs(senderTs - originServerTs) > 20000 ? originServerTs : senderTs; + return ts; + } else return event.getTs(); +} + +/** + * Get the time at which a call took place from a given rtc notification event. + * @param event The notification event + * @param showTwelveHour Whether the time is to be shown in 12 hour format + * @returns A formatted time string + */ +export function getTimeFromEvent(event: MatrixEvent, showTwelveHour: boolean): string { + const ts = getTs(event); + const date = new Date(ts); + const timestamp = formatTime(date, showTwelveHour); + return timestamp; +} diff --git a/apps/web/test/viewmodels/event-tiles/CallTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/CallTileViewModel-test.ts deleted file mode 100644 index 9da5030d50..0000000000 --- a/apps/web/test/viewmodels/event-tiles/CallTileViewModel-test.ts +++ /dev/null @@ -1,145 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { EventType, type MatrixEvent, MatrixEventEvent, RelationType } from "matrix-js-sdk/src/matrix"; -import { CallType } from "@element-hq/web-shared-components"; -import { waitFor } from "jest-matrix-react"; - -import { mkEvent, stubClient } from "../../test-utils"; -import { CallTileViewModel } from "../../../src/viewmodels/room/timeline/event-tile/call/CallTileViewModel"; -import SettingsStore from "../../../src/settings/SettingsStore"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; -import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; - -function getMockedRtcNotificationEvent(intent: string, senderTs: number, serverTs: number): MatrixEvent { - const mockEvent = mkEvent({ - type: EventType.RTCNotification, - user: "@foo:m.org", - content: { - "m.call.intent": intent, - "sender_ts": senderTs, - }, - ts: serverTs, - event: true, - }); - return mockEvent; -} - -function getMockedRtcDeclineEvent(rtcNotificationEvent: MatrixEvent, sender = "@foo:m.org"): MatrixEvent { - const mockEvent = mkEvent({ - type: EventType.RTCDecline, - user: sender, - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: rtcNotificationEvent.getId(), - }, - }, - ts: 924285416000, - event: true, - }); - return mockEvent; -} - -describe("CallTileViewModel", () => { - it("should set voice intent in state", () => { - const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365); - const vm = new CallTileViewModel({ mxEvent }); - const { type } = vm.getSnapshot(); - expect(type).toStrictEqual(CallType.Voice); - }); - - it("should set video intent in state", () => { - const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365); - const vm = new CallTileViewModel({ mxEvent }); - const { type } = vm.getSnapshot(); - expect(type).toStrictEqual(CallType.Video); - }); - - it("should calculate time string correctly", () => { - const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000); - const vm = new CallTileViewModel({ mxEvent }); - const { timestamp } = vm.getSnapshot(); - expect(timestamp).toStrictEqual("17:55"); - }); - - it("should calculate time string correctly when configured to use 12 hour format", async () => { - const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000); - await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true); - const vm = new CallTileViewModel({ mxEvent }); - const { timestamp } = vm.getSnapshot(); - expect(timestamp).toStrictEqual("5:55 PM"); - SettingsStore.reset(); - }); - - it("should change timestamp format when setting is modified", async () => { - const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000); - const vm = new CallTileViewModel({ mxEvent }); - expect(vm.getSnapshot().timestamp).toStrictEqual("17:55"); - await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true); - await waitFor(() => { - expect(vm.getSnapshot().timestamp).toStrictEqual("5:55 PM"); - }); - SettingsStore.reset(); - }); - - describe("On call declined", () => { - it("should calculate isCallDeclined correctly", () => { - const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365); - // When there's no decline event, isCallDeclined = false - const vm1 = new CallTileViewModel({ mxEvent, getRelationsForEvent: jest.fn() }); - expect(vm1.isCallDeclined).toStrictEqual(false); - - // When there's a decline event, isCallDeclined = true - const declineEvent = getMockedRtcDeclineEvent(mxEvent); - const getRelationsForEvent = jest.fn().mockReturnValue({ - getRelations: () => [declineEvent], - }); - const vm2 = new CallTileViewModel({ mxEvent, getRelationsForEvent }); - expect(vm2.isCallDeclined).toStrictEqual(true); - }); - - it("should calculate isCallDeclinedByUs correctly", () => { - const cli = stubClient(); - cli.getUserId = jest.fn().mockReturnValue("@bar:m.org"); - - const mxEvent = getMockedRtcNotificationEvent("audio", 924285348000, 924285348000); - const declineEvent: MatrixEvent[] = []; - const getRelationsForEvent = jest.fn().mockReturnValue({ - getRelations: () => declineEvent, - }); - - // Decline event sent by somebody else - declineEvent.push(getMockedRtcDeclineEvent(mxEvent)); - const vm = new CallTileViewModel({ mxEvent, getRelationsForEvent }); - expect(vm.getSnapshot().isCallDeclinedByUs).toStrictEqual(false); - - // Decline event sent by us - declineEvent.pop(); - declineEvent.push(getMockedRtcDeclineEvent(mxEvent, MatrixClientPeg.get()!.getUserId()!)); - const vm2 = new CallTileViewModel({ mxEvent, getRelationsForEvent }); - expect(vm2.getSnapshot().isCallDeclinedByUs).toStrictEqual(true); - }); - - it("should recompute state when call is declined", () => { - const mxEvent = getMockedRtcNotificationEvent("audio", 924285348000, 924285348000); - const declineEvent: MatrixEvent[] = []; - const getRelationsForEvent = jest.fn().mockReturnValue({ - getRelations: () => declineEvent, - }); - - // No decline event yet, so timestamp should be based on rtc notification event. - const vm = new CallTileViewModel({ mxEvent, getRelationsForEvent }); - expect(vm.getSnapshot().timestamp).toStrictEqual("17:55"); - - // Decline event arrives, timestamp should update to be that of the decline event. - declineEvent.push(getMockedRtcDeclineEvent(mxEvent)); - mxEvent.emit(MatrixEventEvent.RelationsCreated, RelationType.Reference, EventType.RTCDecline); - expect(vm.getSnapshot().timestamp).toStrictEqual("17:56"); - }); - }); -}); diff --git a/knip.ts b/knip.ts index d2053ace5c..893226ec41 100644 --- a/knip.ts +++ b/knip.ts @@ -57,6 +57,7 @@ export default { "!scripts/**!", "!src/test/**!", "!recorder-worklet-loader.cjs!", + "!src/**/*-{mock,mocks,snapshot,actions}.*!", ], ignoreDependencies: [ // False positive diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/default-auto.png deleted file mode 100644 index 1b6547d1ca..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/default-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/video-call-auto.png deleted file mode 100644 index 3e609fa651..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/video-call-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/voice-call-auto.png deleted file mode 100644 index f2adae5a2c..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/voice-call-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/default-auto.png deleted file mode 100644 index ca64e3659e..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/default-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/video-call-auto.png deleted file mode 100644 index c1d96b394d..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/video-call-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/voice-call-auto.png deleted file mode 100644 index 775a3b94cd..0000000000 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/voice-call-auto.png and /dev/null differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..32dbc8d22b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/incoming-video-declined-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/incoming-video-declined-auto.png new file mode 100644 index 0000000000..af03c8bfb9 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/incoming-video-declined-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/call-declined-by-us-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/incoming-voice-declined-auto.png similarity index 100% rename from packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/call-declined-by-us-auto.png rename to packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/incoming-voice-declined-auto.png diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/outgoing-video-declined-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/outgoing-video-declined-auto.png new file mode 100644 index 0000000000..c26f702f29 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/outgoing-video-declined-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/outgoing-voice-declined-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/outgoing-voice-declined-auto.png new file mode 100644 index 0000000000..bed6bc35dd Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/outgoing-voice-declined-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/video-ended-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/video-ended-auto.png new file mode 100644 index 0000000000..b794c3cc36 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/video-ended-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/voice-ended-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/voice-ended-auto.png new file mode 100644 index 0000000000..32dbc8d22b Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx/voice-ended-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..3b1c0066ee Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index dded80a263..51a7aa1296 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -231,6 +231,11 @@ "call_declined": "Call declined", "call_declined_by_us": "You declined a call" }, + "tombstone": { + "room": { + "title": "Group call ended" + } + }, "video_call_title": "Video call", "voice_call_title": "Voice call" }, diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx deleted file mode 100644 index a2dd9f855a..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; - -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { useMockedViewModel } from "../../../../../core/viewmodel"; -import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; -import { CallType, type CallTileViewSnapshot } from "../common/types"; -import { CallDeclinedTileView } from "./CallDeclinedTileView"; - -const CallDeclinedTileViewWrapperImpl = ({ ...rest }: CallTileViewSnapshot): React.ReactNode => { - const vm = useMockedViewModel(rest, {}); - return ; -}; - -const CallDeclinedTileViewWrapper = withViewDocs(CallDeclinedTileViewWrapperImpl, CallDeclinedTileView); - -const meta = { - title: "Timeline/Timeline Event/Call/CallDeclinedTileView", - component: CallDeclinedTileViewWrapper, - tags: ["autodocs"], - argTypes: { - type: { - options: [CallType.Video, CallType.Voice], - control: { type: "select" }, - }, - timestamp: { - control: { type: "text" }, - }, - }, - args: { - type: CallType.Voice, - timestamp: "12:36", - isCallDeclinedByUs: false, - }, - parameters: { - design: { - type: "figma", - url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3914&t=jv0JnUoKJUW1Ko96-4", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const VoiceCall: Story = { - args: { - type: CallType.Voice, - }, -}; - -export const VideoCall: Story = { - args: { - type: CallType.Video, - }, -}; - -export const CallDeclinedByUs: Story = { - args: { - type: CallType.Voice, - isCallDeclinedByUs: true, - }, -}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.test.tsx deleted file mode 100644 index 6fe6250d34..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.test.tsx +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { composeStories } from "@storybook/react-vite"; -import { describe, expect, it } from "vitest"; -import React from "react"; -import { render } from "@test-utils"; - -import * as Stories from "./CallDeclinedTileView.stories"; - -const { VideoCall, VoiceCall, CallDeclinedByUs } = composeStories(Stories); - -describe("CallDeclinedTileView", () => { - describe("renders the tile", () => { - it("voice call", () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - - it("video call", () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - - it("call declined by us", () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - }); -}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.tsx deleted file mode 100644 index f62a124852..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; -import { - VideoCallDeclinedSolidIcon, - VoiceCallDeclinedSolidIcon, -} from "@vector-im/compound-design-tokens/assets/web/icons"; -import classnames from "classnames"; - -import { useViewModel, type ViewModel } from "../../../../../core/viewmodel"; -import { Flex } from "../../../../../core/utils/Flex"; -import styles from "../common/CallTileView.module.css"; -import { useI18n } from "../../../../../core/i18n/i18nContext"; -import { type CallTileViewSnapshot, CallType } from "../common/types"; - -export type CallDeclinedTileViewModel = ViewModel; - -export interface CallDeclinedTileViewProps { - vm: CallDeclinedTileViewModel; - className?: string; -} - -function getIconForCallType(type: CallType): React.ReactNode { - switch (type) { - case CallType.Video: - return ; - case CallType.Voice: - return ; - } -} - -/** - * View for a timeline tile that indicates that a call was declined. - */ -export function CallDeclinedTileView({ vm, className }: CallDeclinedTileViewProps): React.ReactNode { - const { translate: _t } = useI18n(); - const { type, timestamp, isCallDeclinedByUs } = useViewModel(vm); - const classNames = classnames(className, styles.container); - return ( - - {getIconForCallType(type)} -
- {isCallDeclinedByUs - ? _t("timeline|call_tile|declined|call_declined_by_us") - : _t("timeline|call_tile|declined|call_declined")} -
-
{timestamp}
-
- ); -} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/__snapshots__/CallDeclinedTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/__snapshots__/CallDeclinedTileView.test.tsx.snap deleted file mode 100644 index ae94ca0ecf..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/__snapshots__/CallDeclinedTileView.test.tsx.snap +++ /dev/null @@ -1,97 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`CallDeclinedTileView > renders the tile > call declined by us 1`] = ` -
-
- - - -
- You declined a call -
-
- 12:36 -
-
-
-`; - -exports[`CallDeclinedTileView > renders the tile > video call 1`] = ` -
-
- - - -
- Call declined -
-
- 12:36 -
-
-
-`; - -exports[`CallDeclinedTileView > renders the tile > voice call 1`] = ` -
-
- - - -
- Call declined -
-
- 12:36 -
-
-
-`; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx deleted file mode 100644 index e3bed927c6..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; - -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CallStartedTileView } from "./CallStartedTileView"; -import { useMockedViewModel } from "../../../../../core/viewmodel"; -import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; -import { CallType, type CallTileViewSnapshot } from "../common/types"; - -const CallStartedTileViewWrapperImpl = ({ ...rest }: CallTileViewSnapshot): React.ReactNode => { - const vm = useMockedViewModel(rest, {}); - return ; -}; - -const CallStartedTileViewWrapper = withViewDocs(CallStartedTileViewWrapperImpl, CallStartedTileView); - -const meta = { - title: "Timeline/Timeline Event/Call/CallStartedTileView", - component: CallStartedTileViewWrapper, - tags: ["autodocs"], - argTypes: { - type: { - options: [CallType.Video, CallType.Voice], - control: { type: "select" }, - }, - timestamp: { - control: { type: "text" }, - }, - }, - args: { - type: CallType.Voice, - timestamp: "12:36", - }, - parameters: { - design: { - type: "figma", - url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3901&t=OvT1LOc5wH4kXt0a-4", - }, - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const Default: Story = {}; - -export const VoiceCall: Story = { - args: { - type: CallType.Voice, - }, -}; - -export const VideoCall: Story = { - args: { - type: CallType.Video, - }, -}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.test.tsx deleted file mode 100644 index f885b7aa18..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.test.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import { composeStories } from "@storybook/react-vite"; -import { describe, expect, it } from "vitest"; -import React from "react"; -import { render } from "@test-utils"; - -import * as Stories from "./CallStartedTileView.stories"; - -const { VideoCall, VoiceCall } = composeStories(Stories); - -describe("CallStartedTileView", () => { - describe("renders the tile", () => { - it("voice call", () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - - it("video call", () => { - const { container } = render(); - expect(container).toMatchSnapshot(); - }); - }); -}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.tsx deleted file mode 100644 index a6c2a5fdae..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.tsx +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial - * Please see LICENSE files in the repository root for full details. - */ - -import React from "react"; -import { VideoCallSolidIcon, VoiceCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import classnames from "classnames"; - -import { useViewModel, type ViewModel } from "../../../../../core/viewmodel"; -import { Flex } from "../../../../../core/utils/Flex"; -import styles from "../common/CallTileView.module.css"; -import { useI18n } from "../../../../../core/i18n/i18nContext"; -import { type CallTileViewSnapshot, CallType } from "../common/types"; - -export type CallStartedTileViewModel = ViewModel; - -export interface CallStartedTileViewProps { - vm: CallStartedTileViewModel; - className?: string; -} - -function getIconForCallType(type: CallType): React.ReactNode { - switch (type) { - case CallType.Video: - return ; - case CallType.Voice: - return ; - } -} - -/** - * View for a timeline tile that indicates the start of an element call. - */ -export function CallStartedTileView({ vm, className }: CallStartedTileViewProps): React.ReactNode { - const { translate: _t } = useI18n(); - const { type, timestamp } = useViewModel(vm); - const classNames = classnames(className, styles.container); - return ( - - {getIconForCallType(type)} -
- {type === CallType.Voice - ? _t("timeline|call_tile|voice_call_title") - : _t("timeline|call_tile|video_call_title")} -
- -
{timestamp}
-
- ); -} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/__snapshots__/CallStartedTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/__snapshots__/CallStartedTileView.test.tsx.snap deleted file mode 100644 index 83ed1ca614..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/__snapshots__/CallStartedTileView.test.tsx.snap +++ /dev/null @@ -1,65 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`CallStartedTileView > renders the tile > video call 1`] = ` -
-
- - - -
- Video call -
-
- 12:36 -
-
-
-`; - -exports[`CallStartedTileView > renders the tile > voice call 1`] = ` -
-
- - - -
- Voice call -
-
- 12:36 -
-
-
-`; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx new file mode 100644 index 0000000000..dadf9ac9c9 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx @@ -0,0 +1,47 @@ +/* + * 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 { useViewModel, type ViewModel } from "../../../../core/viewmodel"; +import { + RoomTombstoneCallTileView, + type RoomTombstoneCallTileViewModel, +} from "./tombstone/room/RoomTombstoneCallTileView"; +import { DmTombstoneCallTileView, type DmTombstoneCallTileViewModel } from "./tombstone/dm/DmTombstoneCallTileView"; + +/** + * Map from tile type to view model. + */ +interface TileTypeToViewModelMap { + "tombstone-call-room": RoomTombstoneCallTileViewModel; + "tombstone-call-dm": DmTombstoneCallTileViewModel; +} + +export interface RootCallTileViewSnapshot { + tileType: Type; + tileViewModel: TileTypeToViewModelMap[Type]; +} + +export type RootCallTileViewModel = ViewModel; + +interface Props { + vm: RootCallTileViewModel; +} + +/** + * Root view for a call tile in the timeline. + */ +export function RootCallTileView({ vm }: Props): React.ReactNode { + const { tileType, tileViewModel } = useViewModel(vm); + switch (tileType) { + case "tombstone-call-room": + return ; + case "tombstone-call-dm": + return ; + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/common/types.ts b/packages/shared-components/src/room/timeline/event-tile/call/common.ts similarity index 50% rename from packages/shared-components/src/room/timeline/event-tile/call/common/types.ts rename to packages/shared-components/src/room/timeline/event-tile/call/common.ts index 087c3b45bf..d5b25a7957 100644 --- a/packages/shared-components/src/room/timeline/event-tile/call/common/types.ts +++ b/packages/shared-components/src/room/timeline/event-tile/call/common.ts @@ -20,20 +20,9 @@ export const enum CallType { } /** - * The snapshot that both the call started and call declined tiles expect. + * Whether the call is incoming or outgoing. */ -export type CallTileViewSnapshot = { - /** - * What type of call this tile needs to render for. - */ - type: CallType; - /** - * Time when this call was started. - */ - timestamp: string; - /** - * Whether this call was declined by our user. - * Undefined if not rendering a declined call tile. - */ - isCallDeclinedByUs?: boolean; -}; +export const enum CallDirection { + Incoming = "Incoming", + Outgoing = "Outgoing", +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/index.ts b/packages/shared-components/src/room/timeline/event-tile/call/index.ts index 0cca79e866..6a710d4e67 100644 --- a/packages/shared-components/src/room/timeline/event-tile/call/index.ts +++ b/packages/shared-components/src/room/timeline/event-tile/call/index.ts @@ -5,6 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -export * from "./CallStartedTile/CallStartedTileView"; -export * from "./CallDeclinedTile/CallDeclinedTileView"; -export * from "./common/types"; +export * from "./tombstone/room/RoomTombstoneCallTileView"; +export * from "./tombstone/dm/DmTombstoneCallTileView"; +export * from "./RootCallTileView"; +export * from "./common"; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/common/CallTileView.module.css b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/common.module.css similarity index 100% rename from packages/shared-components/src/room/timeline/event-tile/call/common/CallTileView.module.css rename to packages/shared-components/src/room/timeline/event-tile/call/tombstone/common.module.css diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx new file mode 100644 index 0000000000..06c691503b --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.stories.tsx @@ -0,0 +1,102 @@ +/* + * 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 type { Meta, StoryObj } from "@storybook/react-vite"; +import { DmTombstoneCallTileView, type DmTombstoneCallTileViewSnapshot } from "./DmTombstoneCallTileView"; +import { useMockedViewModel } from "../../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../../.storybook/withViewDocs"; +import { CallDirection, CallType } from "../../common"; + +const RoomTombstoneCallTileViewWrapperImpl = (snapshot: DmTombstoneCallTileViewSnapshot): React.ReactNode => { + const vm = useMockedViewModel(snapshot, {}); + return ; +}; + +const RoomTombstoneCallTileViewWrapper = withViewDocs(RoomTombstoneCallTileViewWrapperImpl, DmTombstoneCallTileView); + +const meta = { + title: "Timeline/Timeline Event/Call/Tombstone/DmTombstoneCallTileView", + component: RoomTombstoneCallTileViewWrapper, + tags: ["autodocs"], + argTypes: { + timestamp: { + control: { type: "text" }, + }, + callDirection: { + options: [CallDirection.Incoming, CallDirection.Outgoing], + control: { type: "radio" }, + }, + + type: { + options: [CallType.Voice, CallType.Video], + control: { type: "radio" }, + }, + }, + args: { + timestamp: "12:36", + callDirection: CallDirection.Incoming, + isCallDeclined: false, + type: CallType.Voice, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3905&t=iEfhUcFrV01fQeyQ-4", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const VoiceEnded: Story = { + args: { + type: CallType.Voice, + }, +}; + +export const VideoEnded: Story = { + args: { + type: CallType.Video, + }, +}; + +export const IncomingVoiceDeclined: Story = { + args: { + type: CallType.Voice, + callDirection: CallDirection.Incoming, + isCallDeclined: true, + }, +}; + +export const IncomingVideoDeclined: Story = { + args: { + type: CallType.Video, + callDirection: CallDirection.Incoming, + isCallDeclined: true, + }, +}; + +export const OutgoingVoiceDeclined: Story = { + args: { + type: CallType.Voice, + callDirection: CallDirection.Outgoing, + isCallDeclined: true, + }, +}; + +export const OutgoingVideoDeclined: Story = { + args: { + type: CallType.Video, + callDirection: CallDirection.Outgoing, + isCallDeclined: true, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.test.tsx new file mode 100644 index 0000000000..083b022354 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.test.tsx @@ -0,0 +1,39 @@ +/* + * 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 { composeStories } from "@storybook/react-vite"; +import { describe, expect, it } from "vitest"; +import React from "react"; +import { render } from "@test-utils"; + +import * as Stories from "./DmTombstoneCallTileView.stories"; + +const { IncomingVideoDeclined, OutgoingVideoDeclined, VideoEnded, VoiceEnded } = composeStories(Stories); + +describe("DmTombstoneCallTileView", () => { + describe("renders the tile", () => { + it("IncomingVideoDeclined", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("OutgoingVideoDeclined", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("VideoEnded", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("VoiceEnded", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.tsx new file mode 100644 index 0000000000..0ee38f97d9 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/DmTombstoneCallTileView.tsx @@ -0,0 +1,96 @@ +/* + * 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 { + VideoCallSolidIcon, + VideoCallDeclinedSolidIcon, + VoiceCallDeclinedSolidIcon, + VoiceCallSolidIcon, +} from "@vector-im/compound-design-tokens/assets/web/icons"; +import classnames from "classnames"; + +import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel"; +import { Flex } from "../../../../../../core/utils/Flex"; +import styles from "../common.module.css"; +import { useI18n } from "../../../../../../core/i18n/i18nContext"; +import { CallDirection, CallType } from "../../common"; +import { type RoomTombstoneCallTileViewSnapshot } from "../room/RoomTombstoneCallTileView"; + +export interface DmTombstoneCallTileViewSnapshot extends RoomTombstoneCallTileViewSnapshot { + /** + * What type of call this tile needs to render for. + */ + type: CallType; + + /** + * Whether this is an incoming or outgoing call. + */ + callDirection: CallDirection; + + /** + * Whether this call was declined. + */ + isCallDeclined: boolean; +} + +export type DmTombstoneCallTileViewModel = ViewModel; + +export interface DmTombstoneCallTileViewProps { + vm: DmTombstoneCallTileViewModel; + + /** + * Additional class names for this component. + */ + className?: string; +} + +function getIcon(type: CallType, isCallDeclined: boolean): React.ReactNode { + const VideoIcon = isCallDeclined ? VideoCallDeclinedSolidIcon : VideoCallSolidIcon; + const VoiceIcon = isCallDeclined ? VoiceCallDeclinedSolidIcon : VoiceCallSolidIcon; + switch (type) { + case CallType.Video: + return ; + case CallType.Voice: + return ; + } +} + +/** + * Renders the tombstone content for a tile in a DM. + */ +export function DmTombstoneCallTileView({ vm, className }: DmTombstoneCallTileViewProps): React.ReactNode { + const snapshot = useViewModel(vm); + const { type, timestamp, isCallDeclined } = snapshot; + const classNames = classnames(className, styles.container); + return ( + + {getIcon(type, isCallDeclined)} +
+ {isCallDeclined ? : } +
+ +
{timestamp}
+
+ ); +} + +function NormalContent(props: { snapshot: DmTombstoneCallTileViewSnapshot }): React.ReactNode { + const { type } = props.snapshot; + const { translate: _t } = useI18n(); + return type === CallType.Voice + ? _t("timeline|call_tile|voice_call_title") + : _t("timeline|call_tile|video_call_title"); +} + +function DeclinedContent(props: { snapshot: DmTombstoneCallTileViewSnapshot }): React.ReactNode { + const { callDirection } = props.snapshot; + const { translate: _t } = useI18n(); + return callDirection === CallDirection.Incoming + ? _t("timeline|call_tile|declined|call_declined_by_us") + : _t("timeline|call_tile|declined|call_declined"); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/__snapshots__/DmTombstoneCallTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/__snapshots__/DmTombstoneCallTileView.test.tsx.snap new file mode 100644 index 0000000000..f69892bb48 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/dm/__snapshots__/DmTombstoneCallTileView.test.tsx.snap @@ -0,0 +1,129 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DmTombstoneCallTileView > renders the tile > IncomingVideoDeclined 1`] = ` +
+
+ + + +
+ You declined a call +
+
+ 12:36 +
+
+
+`; + +exports[`DmTombstoneCallTileView > renders the tile > OutgoingVideoDeclined 1`] = ` +
+
+ + + +
+ Call declined +
+
+ 12:36 +
+
+
+`; + +exports[`DmTombstoneCallTileView > renders the tile > VideoEnded 1`] = ` +
+
+ + + +
+ Video call +
+
+ 12:36 +
+
+
+`; + +exports[`DmTombstoneCallTileView > renders the tile > VoiceEnded 1`] = ` +
+
+ + + +
+ Voice call +
+
+ 12:36 +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.stories.tsx new file mode 100644 index 0000000000..94807886f8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.stories.tsx @@ -0,0 +1,45 @@ +/* + * 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 type { Meta, StoryObj } from "@storybook/react-vite"; +import { RoomTombstoneCallTileView, type RoomTombstoneCallTileViewSnapshot } from "./RoomTombstoneCallTileView"; +import { useMockedViewModel } from "../../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../../.storybook/withViewDocs"; + +const RoomTombstoneCallTileViewWrapperImpl = ({ ...rest }: RoomTombstoneCallTileViewSnapshot): React.ReactNode => { + const vm = useMockedViewModel(rest, {}); + return ; +}; + +const RoomTombstoneCallTileViewWrapper = withViewDocs(RoomTombstoneCallTileViewWrapperImpl, RoomTombstoneCallTileView); + +const meta = { + title: "Timeline/Timeline Event/Call/Tombstone/RoomTombstoneCallTileView", + component: RoomTombstoneCallTileViewWrapper, + tags: ["autodocs"], + argTypes: { + timestamp: { + control: { type: "text" }, + }, + }, + args: { + timestamp: "12:36", + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3902&t=oRWAGiwV5pUV4OFF-4", + }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.test.tsx new file mode 100644 index 0000000000..3d3bbfc1cd --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.test.tsx @@ -0,0 +1,22 @@ +/* + * 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 { composeStories } from "@storybook/react-vite"; +import { describe, expect, it } from "vitest"; +import React from "react"; +import { render } from "@test-utils"; + +import * as Stories from "./RoomTombstoneCallTileView.stories"; + +const { Default } = composeStories(Stories); + +describe("RoomTombstoneCallTileView", () => { + it("renders the tile", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.tsx new file mode 100644 index 0000000000..063b5414dc --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/RoomTombstoneCallTileView.tsx @@ -0,0 +1,49 @@ +/* + * 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 { VideoCallDeclinedSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import classnames from "classnames"; + +import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel"; +import { Flex } from "../../../../../../core/utils/Flex"; +import styles from "../common.module.css"; +import { useI18n } from "../../../../../../core/i18n/i18nContext"; + +export type RoomTombstoneCallTileViewSnapshot = { + /** + * Time when this call was started. + */ + timestamp: string; +}; + +export type RoomTombstoneCallTileViewModel = ViewModel; + +export interface CallStartedTileViewProps { + vm: RoomTombstoneCallTileViewModel; + + /** + * Additional class names for this component. + */ + className?: string; +} + +/** + * Renders the tombstone content for a call in a room. + */ +export function RoomTombstoneCallTileView({ vm, className }: CallStartedTileViewProps): React.ReactNode { + const { translate: _t } = useI18n(); + const { timestamp } = useViewModel(vm); + const classNames = classnames(className, styles.container); + return ( + + +
{_t("timeline|call_tile|tombstone|room|title")}
+
{timestamp}
+
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/__snapshots__/RoomTombstoneCallTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/__snapshots__/RoomTombstoneCallTileView.test.tsx.snap new file mode 100644 index 0000000000..44e3034915 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/tombstone/room/__snapshots__/RoomTombstoneCallTileView.test.tsx.snap @@ -0,0 +1,33 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RoomTombstoneCallTileView > renders the tile 1`] = ` +
+
+ + + +
+ Group call ended +
+
+ 12:36 +
+
+
+`; diff --git a/vitest.config.ts b/vitest.config.ts index b93610dfac..0be7b66ecb 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -58,6 +58,8 @@ export default defineConfig({ "**/*.{stories,test}.{ts,tsx}", // Exclude test utilities "**/src/test/**", + // Exclude mocks + "**/src/**/*-{mock,mocks}.{ts,tsx}", // Exclude type definition files "**/*.d.ts", // Exclude playwright-common as it is just test utilities