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:
@@ -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 ||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user