Call Tile - Render a tile showing that a call was started (#32988)

* Ignore specific directories

Otherwise newly generated screenshots will be ignored.

* Create a CallStartedTileView

This view will render a tile that shows when an EC call was started in
the timeline.

* Add storybook tests

* Add vite tests

* Export the view from shared-components package

* Add a viewmodel for driving the view

* Support rendering the tile in the tile factory

* Fix tile rendering

* Add comment to explain css height

* Use semantic token for gap

* Update snapshot

* Use min-height over height

This will scale the element if the user sets a custom font size.

* Don't show timestamp for call started tile

Timestamp is already shown as a part of the tile content.

* Fix broken tile on bubble layout

The tile should be full-width and not shown within a bubble.

* Fix tests

* Update storybook title
This commit is contained in:
R Midhun Suresh
2026-05-11 21:12:51 +00:00
committed by GitHub
parent c62acc6634
commit 2933b51fea
16 changed files with 448 additions and 1 deletions
@@ -717,6 +717,9 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
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<EventTileProps, IState>
} 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<EventTileProps, IState>
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 ||
+12
View File
@@ -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) => <HiddenBodyWrappedView ref={ref} {...props} />;
function CallStartedTileViewWrapped({ mxEvent }: IBodyProps): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new CallStartedTileViewModel({ mxEvent }));
return <CallStartedTileView vm={vm} />;
}
export const CallStartedEventFactory: Factory = (ref, props) => {
return <CallStartedTileViewWrapped {...props} />;
};
// These factories are exported for reference comparison against pickFactory()
export const JitsiEventFactory: Factory = (ref, props) => <MJitsiWidgetEvent ref={ref} {...props} />;
export const JSONEventFactory: Factory = (ref, props) => <ViewSourceEvent ref={ref} {...props} />;
@@ -135,6 +146,7 @@ const EVENT_TILE_TYPES = new Map<string, Factory>([
[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<string, Factory>([
@@ -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 ||
@@ -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<IRTCNotificationContent>();
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<IRTCNotificationContent>();
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 });
};
}
@@ -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");
});
});
});