diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index 830f427614..7c53a97736 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -18,6 +18,7 @@ import { M_POLL_START, } from "matrix-js-sdk/src/matrix"; import { + CallDeclinedTileView, CallStartedTileView, EncryptionEventView, HiddenBodyView, @@ -56,7 +57,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 { CallStartedTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel"; +import { CallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallTileViewModel"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps extends Pick< @@ -187,9 +188,9 @@ function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { } const RoomAvatarEventFactory: Factory = (ref, props) => ; -function CallStartedTileViewWrapped({ mxEvent }: IBodyProps): JSX.Element { - const vm = useCreateAutoDisposedViewModel(() => new CallStartedTileViewModel({ mxEvent })); - return ; +function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element { + const vm = useCreateAutoDisposedViewModel(() => new CallTileViewModel({ mxEvent, getRelationsForEvent })); + return vm.isCallDeclined ? : ; } export const CallStartedEventFactory: Factory = (ref, props) => { diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel.ts deleted file mode 100644 index 514c7c7c89..0000000000 --- a/apps/web/src/viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel.ts +++ /dev/null @@ -1,79 +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 CallStartedTileViewSnapshot } from "@element-hq/web-shared-components"; - -import type { MatrixEvent } 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"; - -export interface CallStartedTileViewModelProps { - mxEvent: MatrixEvent; -} - -function getIntentFromEvent(event: MatrixEvent): CallStartedTileViewSnapshot["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 getTimeFromEvent(event: MatrixEvent, showTwelveHour: boolean): CallStartedTileViewSnapshot["timestamp"] { - const content = event.getContent(); - const senderTs = content["sender_ts"]; - const originServerTs = event.getTs(); - const ts = Math.abs(senderTs - originServerTs) > 20000 ? originServerTs : senderTs; - - const date = new Date(ts); - const timestamp = formatTime(date, showTwelveHour); - return timestamp; -} - -function getInitialSnapshot(event: MatrixEvent): CallStartedTileViewSnapshot { - const type = getIntentFromEvent(event); - const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); - const timestamp = getTimeFromEvent(event, showTwelveHour); - return { type, timestamp }; -} - -function isSettingsChangedPayload(payload: ActionPayload): payload is SettingUpdatedPayload { - return payload.action === Action.SettingUpdated; -} - -/** - * ViewModel for a timeline tile that indicates the start of an element call. - */ -export class CallStartedTileViewModel extends BaseViewModel< - CallStartedTileViewSnapshot, - CallStartedTileViewModelProps -> { - public constructor(props: CallStartedTileViewModelProps) { - super(props, getInitialSnapshot(props.mxEvent)); - 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 = getTimeFromEvent(this.props.mxEvent, showTwelveHour); - this.snapshot.merge({ timestamp }); - }; -} 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 new file mode 100644 index 0000000000..cfae52080b --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/CallTileViewModel.ts @@ -0,0 +1,141 @@ +/* + * 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/test/viewmodels/event-tiles/CallStartedTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/CallStartedTileViewModel-test.ts deleted file mode 100644 index 166407da2b..0000000000 --- a/apps/web/test/viewmodels/event-tiles/CallStartedTileViewModel-test.ts +++ /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 { type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; -import { CallType } from "@element-hq/web-shared-components"; -import { waitFor } from "jest-matrix-react"; - -import { mkEvent } from "../../test-utils"; -import { CallStartedTileViewModel } from "../../../src/viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel"; -import SettingsStore from "../../../src/settings/SettingsStore"; -import { SettingLevel } from "../../../src/settings/SettingLevel"; - -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; -} - -describe("CallStartedTileViewModel", () => { - it("should set voice intent in state", () => { - const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365); - const vm = new CallStartedTileViewModel({ 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 CallStartedTileViewModel({ 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 CallStartedTileViewModel({ 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 CallStartedTileViewModel({ 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 CallStartedTileViewModel({ 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"); - }); - }); -}); diff --git a/apps/web/test/viewmodels/event-tiles/CallTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/CallTileViewModel-test.ts new file mode 100644 index 0000000000..9da5030d50 --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/CallTileViewModel-test.ts @@ -0,0 +1,145 @@ +/* + * 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/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/CallDeclinedTile/CallDeclinedTileView.stories.tsx/call-declined-by-us-auto.png new file mode 100644 index 0000000000..23d6e4af67 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/call-declined-by-us-auto.png differ 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 new file mode 100644 index 0000000000..1b6547d1ca Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/default-auto.png 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 new file mode 100644 index 0000000000..3e609fa651 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/video-call-auto.png 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 new file mode 100644 index 0000000000..f2adae5a2c Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx/voice-call-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 83fa213d22..9cd0605fed 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -203,6 +203,10 @@ }, "timeline": { "call_tile": { + "declined": { + "call_declined": "Call declined", + "call_declined_by_us": "You declined a call" + }, "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 new file mode 100644 index 0000000000..a2dd9f855a --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.stories.tsx @@ -0,0 +1,71 @@ +/* + * 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 new file mode 100644 index 0000000000..6fe6250d34 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 new file mode 100644 index 0000000000..f62a124852 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/CallDeclinedTileView.tsx @@ -0,0 +1,55 @@ +/* + * 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 new file mode 100644 index 0000000000..ae94ca0ecf --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallDeclinedTile/__snapshots__/CallDeclinedTileView.test.tsx.snap @@ -0,0 +1,97 @@ +// 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 index 89212b0071..e3bed927c6 100644 --- 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 @@ -8,11 +8,12 @@ import React from "react"; import type { Meta, StoryObj } from "@storybook/react-vite"; -import { CallStartedTileView, type CallStartedTileViewSnapshot, CallType } from "./CallStartedTileView"; +import { CallStartedTileView } from "./CallStartedTileView"; import { useMockedViewModel } from "../../../../../core/viewmodel"; import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { CallType, type CallTileViewSnapshot } from "../common/types"; -const CallStartedTileViewWrapperImpl = ({ ...rest }: CallStartedTileViewSnapshot): React.ReactNode => { +const CallStartedTileViewWrapperImpl = ({ ...rest }: CallTileViewSnapshot): React.ReactNode => { const vm = useMockedViewModel(rest, {}); return ; }; 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 index 53e4619aee..a6c2a5fdae 100644 --- 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 @@ -11,35 +11,11 @@ import classnames from "classnames"; import { useViewModel, type ViewModel } from "../../../../../core/viewmodel"; import { Flex } from "../../../../../core/utils/Flex"; -import styles from "./CallStartedTileView.module.css"; +import styles from "../common/CallTileView.module.css"; import { useI18n } from "../../../../../core/i18n/i18nContext"; +import { type CallTileViewSnapshot, CallType } from "../common/types"; -/** - * Represents whether a call is a voice call or video call. - */ -export const enum CallType { - /** - * This is a voice call. - */ - Voice = "voice", - /** - * This is a video call. - */ - Video = "video", -} - -export type CallStartedTileViewSnapshot = { - /** - * What type of call this tile needs to render for. - */ - type: CallType; - /** - * Time when this call was started. - */ - timestamp: string; -}; - -export type CallStartedTileViewModel = ViewModel; +export type CallStartedTileViewModel = ViewModel; export interface CallStartedTileViewProps { vm: CallStartedTileViewModel; 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 index eb67bd154a..83ed1ca614 100644 --- 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 @@ -3,11 +3,11 @@ exports[`CallStartedTileView > renders the tile > video call 1`] = `
renders the tile > video call 1`] = ` />
Video call
12:36
@@ -35,11 +35,11 @@ exports[`CallStartedTileView > renders the tile > video call 1`] = ` exports[`CallStartedTileView > renders the tile > voice call 1`] = `
renders the tile > voice call 1`] = ` />
Voice call
12:36
diff --git a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.module.css b/packages/shared-components/src/room/timeline/event-tile/call/common/CallTileView.module.css similarity index 97% rename from packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.module.css rename to packages/shared-components/src/room/timeline/event-tile/call/common/CallTileView.module.css index c7b03256f1..94a1287e80 100644 --- a/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.module.css +++ b/packages/shared-components/src/room/timeline/event-tile/call/common/CallTileView.module.css @@ -13,6 +13,7 @@ border-radius: var(--cpd-space-2x); padding: var(--cpd-space-2x) var(--cpd-space-3x); box-sizing: border-box; + margin: 10px 0; } .title { 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/types.ts new file mode 100644 index 0000000000..087c3b45bf --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/common/types.ts @@ -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. + */ + +/** + * Represents whether a call is a voice call or video call. + */ +export const enum CallType { + /** + * This is a voice call. + */ + Voice = "voice", + /** + * This is a video call. + */ + Video = "video", +} + +/** + * The snapshot that both the call started and call declined tiles expect. + */ +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; +}; 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 fc4fb78c6f..0cca79e866 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 @@ -6,3 +6,5 @@ */ export * from "./CallStartedTile/CallStartedTileView"; +export * from "./CallDeclinedTile/CallDeclinedTileView"; +export * from "./common/types";