Call Tile - Support declined call tile (#33371)

* Extract shared types and css

* Add CallDeclinedTileView

* Add storybook and view tests

* Support declined event in view model

* Render declined view from tile factory

* Update snapshots

* Add 10px padding to top and bottom

* Distinguish between call declined by us and other users

* Support `isCallDeclinedByUs` in view model

* Update tests

* Add better comments

* Rename getInitial to generateSnapshot
This commit is contained in:
R Midhun Suresh
2026-05-14 14:12:05 +00:00
committed by GitHub
parent 92aa3202e3
commit 435acf1ba7
20 changed files with 608 additions and 191 deletions
+5 -4
View File
@@ -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) => <RoomAvatarEventWrappedView ref={ref} {...props} />;
function CallStartedTileViewWrapped({ mxEvent }: IBodyProps): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new CallStartedTileViewModel({ mxEvent }));
return <CallStartedTileView vm={vm} />;
function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new CallTileViewModel({ mxEvent, getRelationsForEvent }));
return vm.isCallDeclined ? <CallDeclinedTileView vm={vm} /> : <CallStartedTileView vm={vm} />;
}
export const CallStartedEventFactory: Factory = (ref, props) => {
@@ -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<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,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<IRTCNotificationContent>();
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<IRTCNotificationContent>();
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<CallTileViewSnapshot, CallTileViewModelProps> {
/**
* 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;
}
}