diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 0590c06887..171cc38c0c 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -717,6 +717,9 @@ export class UnwrappedEventTile extends React.Component if (this.props.isRedacted) return false; + // This event is a room mention but we don't want the call tile to have a highlight. + if (this.props.mxEvent.getType() === EventType.RTCNotification) return false; + const cli = MatrixClientPeg.safeGet(); const actions = cli.getPushActionsForEvent(this.props.mxEvent.replacingEvent() || this.props.mxEvent); // get the actions for the previous version of the event too if it is an edit @@ -1119,7 +1122,8 @@ export class UnwrappedEventTile extends React.Component } else if ( (this.props.continuation && this.context.timelineRenderingType !== TimelineRenderingType.File) || eventType === EventType.CallInvite || - ElementCallEventType.matches(eventType) + ElementCallEventType.matches(eventType) || + eventType === EventType.RTCNotification ) { // no avatar or sender profile for continuation messages and call tiles avatarSize = null; @@ -1197,6 +1201,9 @@ export class UnwrappedEventTile extends React.Component const showTimestamp = this.props.mxEvent.getTs() && + // Don't show timestamp for the CallStarted tile because + // the tile content already renders a timestamp. + this.props.mxEvent.getType() !== EventType.RTCNotification && !this.props.hideTimestamp && (this.props.alwaysShowTimestamps || this.props.last || diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index 152a69c1ec..6ac6adf9cb 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 { + CallStartedTileView, EncryptionEventView, HiddenBodyView, MKeyVerificationRequestView, @@ -51,6 +52,7 @@ import { MKeyVerificationRequestViewModel } from "../viewmodels/room/timeline/ev import { TextualEventViewModel } from "../viewmodels/room/timeline/event-tile/TextualEventViewModel"; import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body/HiddenBodyViewModel"; import { ElementCallEventType } from "../call-types"; +import { CallStartedTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel"; // Subset of EventTile's IProps plus some mixins export interface EventTileTypeProps extends Pick< @@ -122,6 +124,15 @@ function HiddenBodyWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element { } const HiddenEventFactory: Factory = (ref, props) => ; +function CallStartedTileViewWrapped({ mxEvent }: IBodyProps): JSX.Element { + const vm = useCreateAutoDisposedViewModel(() => new CallStartedTileViewModel({ mxEvent })); + return ; +} + +export const CallStartedEventFactory: Factory = (ref, props) => { + return ; +}; + // These factories are exported for reference comparison against pickFactory() export const JitsiEventFactory: Factory = (ref, props) => ; export const JSONEventFactory: Factory = (ref, props) => ; @@ -135,6 +146,7 @@ const EVENT_TILE_TYPES = new Map([ [M_POLL_END.name, MessageEventFactory], [M_POLL_END.altName, MessageEventFactory], [EventType.CallInvite, LegacyCallEventFactory as Factory], // note that this requires a special factory type + [EventType.RTCNotification, CallStartedEventFactory], ]); const STATE_EVENT_TILE_TYPES = new Map([ diff --git a/apps/web/src/utils/EventRenderingUtils.ts b/apps/web/src/utils/EventRenderingUtils.ts index b99e01a07b..2295cfd144 100644 --- a/apps/web/src/utils/EventRenderingUtils.ts +++ b/apps/web/src/utils/EventRenderingUtils.ts @@ -35,6 +35,7 @@ const calcIsInfoMessage = ( eventType !== EventType.RoomMessageEncrypted && eventType !== EventType.Sticker && eventType !== EventType.RoomCreate && + eventType !== EventType.RTCNotification && !M_POLL_START.matches(eventType) && !M_POLL_END.matches(eventType) && !M_BEACON_INFO.matches(eventType) @@ -76,6 +77,7 @@ export function getEventDisplayInfo( // Info messages are basically information about commands processed on a room let isBubbleMessage = + eventType === EventType.RTCNotification || eventType.startsWith("m.key.verification") || (eventType === EventType.RoomMessage && msgtype?.startsWith("m.key.verification")) || eventType === EventType.RoomCreate || 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 new file mode 100644 index 0000000000..514c7c7c89 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/CallStartedTileViewModel.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 { 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/test/viewmodels/event-tiles/CallStartedTileViewModel-test.ts b/apps/web/test/viewmodels/event-tiles/CallStartedTileViewModel-test.ts new file mode 100644 index 0000000000..166407da2b --- /dev/null +++ b/apps/web/test/viewmodels/event-tiles/CallStartedTileViewModel-test.ts @@ -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 { 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/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 new file mode 100644 index 0000000000..ca64e3659e Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/default-auto.png 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 new file mode 100644 index 0000000000..c1d96b394d Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx/video-call-auto.png 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 new file mode 100644 index 0000000000..775a3b94cd Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.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 3bb8915e14..bf2775e354 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -199,6 +199,10 @@ "n_minutes_ago": "%(num)s minutes ago" }, "timeline": { + "call_tile": { + "video_call_title": "Video call", + "voice_call_title": "Voice call" + }, "decryption_failure": { "blocked": "The sender has blocked you from receiving this message because your device is unverified", "historical_event_no_key_backup": "Historical messages are not available on this device", diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 23bc9d4758..fa52da1a02 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -39,6 +39,7 @@ export * from "./room/timeline/TimelineSeparator"; export * from "./room/timeline/event-tile/actions/ActionBarView"; export * from "./room/timeline/event-tile/EventTileView/DisambiguatedProfile"; export * from "./room/timeline/event-tile/EventTileView/EncryptionEventView"; +export * from "./room/timeline/event-tile/call"; export * from "./room/timeline/event-tile/EventTileView/EventTileBubble"; export * from "./room/timeline/event-tile/EventTileView/MKeyVerificationRequestView"; export * from "./room/timeline/event-tile/EventTileView/PinnedMessageBadge"; 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/CallStartedTile/CallStartedTileView.module.css new file mode 100644 index 0000000000..c7b03256f1 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.module.css @@ -0,0 +1,30 @@ +/* + * 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. + */ + +.container { + /* This is the height of the tile as per design */ + min-height: 39px; + width: 100%; + border: 1px solid var(--cpd-color-border-interactive-secondary); + border-radius: var(--cpd-space-2x); + padding: var(--cpd-space-2x) var(--cpd-space-3x); + box-sizing: border-box; +} + +.title { + font: var(--cpd-font-body-md-semibold); + flex: 1; +} + +.time { + font: var(--cpd-font-body-xs-regular); + color: var(--cpd-color-text-secondary); +} + +.icon { + color: var(--cpd-color-icon-secondary); +} 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 new file mode 100644 index 0000000000..89212b0071 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.stories.tsx @@ -0,0 +1,62 @@ +/* + * 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, type CallStartedTileViewSnapshot, CallType } from "./CallStartedTileView"; +import { useMockedViewModel } from "../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; + +const CallStartedTileViewWrapperImpl = ({ ...rest }: CallStartedTileViewSnapshot): 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 new file mode 100644 index 0000000000..f885b7aa18 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.test.tsx @@ -0,0 +1,29 @@ +/* + * 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 new file mode 100644 index 0000000000..53e4619aee --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/CallStartedTileView.tsx @@ -0,0 +1,77 @@ +/* + * 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 "./CallStartedTileView.module.css"; +import { useI18n } from "../../../../../core/i18n/i18nContext"; + +/** + * 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 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 new file mode 100644 index 0000000000..eb67bd154a --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/CallStartedTile/__snapshots__/CallStartedTileView.test.tsx.snap @@ -0,0 +1,65 @@ +// 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/index.ts b/packages/shared-components/src/room/timeline/event-tile/call/index.ts new file mode 100644 index 0000000000..fc4fb78c6f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export * from "./CallStartedTile/CallStartedTileView";