Implement tombstone tiles for calls in room and DM (#34141)

* Implement tombstone tile view for a room

* Implement tombstone tile view for a DM

* Implement root call tile view

* Export all the views

* Add screenshots

* Remove old view code

* Implement vm for tombstone tile in a room

* Implement vm for tombstone tile in a DM

* Implement root tile vm

* Use new tile in the event factory

* Remove old vm

* Exclude mock code from code checks
This commit is contained in:
R Midhun Suresh
2026-07-07 16:27:26 +00:00
committed by GitHub
parent 1b10122a7b
commit e46acef445
50 changed files with 1239 additions and 777 deletions
+12 -5
View File
@@ -18,13 +18,12 @@ import {
M_POLL_START,
} from "matrix-js-sdk/src/matrix";
import {
CallDeclinedTileView,
CallStartedTileView,
EncryptionEventView,
HiddenBodyView,
MJitsiWidgetEventView,
MKeyVerificationRequestView,
RoomAvatarEventView,
RootCallTileView,
TextualEventView,
ViewSourceEventView,
useCreateAutoDisposedViewModel,
@@ -57,7 +56,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 { CallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/CallTileViewModel";
import { RootCallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/RootCallTileViewModel";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps extends Pick<
@@ -189,8 +188,16 @@ function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const RoomAvatarEventFactory: Factory = (ref, props) => <RoomAvatarEventWrappedView ref={ref} {...props} />;
function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element {
const vm = useCreateAutoDisposedViewModel(() => new CallTileViewModel({ mxEvent, getRelationsForEvent }));
return vm.isCallDeclined ? <CallDeclinedTileView vm={vm} /> : <CallStartedTileView vm={vm} />;
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(
() =>
new RootCallTileViewModel({
mxEvent,
getRelationsForEvent,
cli,
}),
);
return <RootCallTileView vm={vm} />;
}
export const CallStartedEventFactory: Factory = (ref, props) => {
@@ -1,141 +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 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;
}
}
@@ -0,0 +1,100 @@
/*
* 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.
*/
// @vitest-environment happy-dom
import { it, describe, expect, vi } from "vitest";
import { type EventTimeline, EventType, type MatrixEvent, type RoomState } from "matrix-js-sdk/src/matrix";
import { mkEvent, mkMessage, mkRoomMember, mkStubRoom, stubClient } from "../../../../../../test/test-utils";
import { getMockedRtcNotificationEvent, MockedCall, MockedCallStore } from "./call-mocks";
import { RootCallTileViewModel } from "./RootCallTileViewModel";
function getEvents(): MatrixEvent[] {
const message1 = mkMessage({
room: "!my-room:m.org",
user: "@alice:m.org",
event: true,
msg: "hello",
});
const message2 = mkMessage({
room: "!my-room:m.org",
user: "@bob:m.org",
event: true,
msg: "hello",
});
const oldRtcNotificationEvent = mkEvent({
type: EventType.RTCNotification,
id: "old-event",
content: {
"m.call.intent": "video",
},
user: "@alice:m.org",
event: true,
});
const latestRtcNotificationEvent = mkEvent({
type: EventType.RTCNotification,
id: "new-event",
content: {
"m.call.intent": "video",
},
user: "@bob:m.org",
event: true,
});
return [message1, message2, oldRtcNotificationEvent, latestRtcNotificationEvent];
}
function getMocked(userIds: string[]) {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.getId = () => "new-event";
const call = MockedCall.create();
const callStore = MockedCallStore.create(call);
const members = userIds.map((id) => mkRoomMember("!my-room:m.org", id));
const room = mkStubRoom("!my-room:m.org", "My Room");
cli.getRoom = () => room;
vi.spyOn(room, "getMembers").mockReturnValue(members);
const events = getEvents();
vi.spyOn(room, "getLiveTimeline").mockImplementation(() => {
return {
getEvents: () => {
return events;
},
getState: (): RoomState => {
return {
mayClientSendStateEvent: () => true,
} as unknown as RoomState;
},
} as unknown as EventTimeline;
});
return { callStore, cli, mxEvent, call };
}
describe("RootCallTileViewModel", () => {
it("computes correct tileType for tombstone call in DM", () => {
const { cli, mxEvent } = getMocked(["@alice:m.org", "@bob:m.org"]);
const vm = new RootCallTileViewModel({ cli, mxEvent });
expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-dm");
});
it("computes correct tileType for tombstone call in Room", () => {
const { cli, mxEvent } = getMocked(["@alice:m.org", "@bob:m.org", "@jack:m.org"]);
const vm = new RootCallTileViewModel({ cli, mxEvent });
expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-room");
});
});
@@ -0,0 +1,69 @@
/*
* 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 MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { BaseViewModel, type RootCallTileViewSnapshot } from "@element-hq/web-shared-components";
import type { GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile";
import { DmTombstoneCallTileViewModel } from "./tiles/tombstone/DmTombstoneCallTileViewModel";
import { RoomTombstoneCallTileViewModel } from "./tiles/tombstone/RoomTombstoneCallTileViewModel";
interface Props {
/**
* Event of type `org.matrix.msc4075.rtc.notification`.
*/
mxEvent: MatrixEvent;
/**
* Helper to fetch related events from a given event.
*/
getRelationsForEvent?: GetRelationsForEvent;
/**
* The {@link MatrixClient} object to access js-sdk API.
*/
cli: MatrixClient;
}
function computeSnapshot(props: Props): RootCallTileViewSnapshot {
const cli = props.cli;
const notificationEvent = props.mxEvent;
// Get the room where this call is taking place
const roomId = props.mxEvent.getRoomId();
if (!roomId) throw new Error("Notification event does not have associated room-id");
const room = cli.getRoom(roomId);
if (!room) throw new Error(`No room with id ${roomId}`);
// This is the same logic used for hiding/showing the voice call button.
const isDmRoom = room.getMembers().length <= 2;
if (isDmRoom) {
return {
tileType: "tombstone-call-dm",
tileViewModel: new DmTombstoneCallTileViewModel({
mxEvent: notificationEvent,
getRelationsForEvent: props.getRelationsForEvent,
cli: props.cli,
}),
};
} else
return {
tileType: "tombstone-call-room",
tileViewModel: new RoomTombstoneCallTileViewModel({ mxEvent: notificationEvent }),
};
}
/**
* The root call tile view model which decides what call tile should be rendered.
*/
export class RootCallTileViewModel extends BaseViewModel<RootCallTileViewSnapshot, Props> {
public constructor(props: Props) {
const snapshot = computeSnapshot(props);
super(props, snapshot);
}
}
@@ -0,0 +1,122 @@
/*
* 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 { EventEmitter } from "events";
import { type RoomMember, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "../../../../../../test/test-utils";
import { type ElementCall } from "../../../../../models/Call";
import type { CallStore } from "../../../../../stores/CallStore";
import type { CallMembership, MatrixRTCSession } from "matrix-js-sdk/src/matrixrtc";
export function getMockedRtcNotificationEvent(
intent: string,
senderTs: number,
serverTs: number,
sender: string = "@foo:m.org",
): MatrixEvent {
const mockEvent = mkEvent({
type: "org.matrix.msc4075.rtc.notification",
user: sender,
content: {
"m.call.intent": intent,
"sender_ts": senderTs,
},
ts: serverTs,
event: true,
room: "!my-room:m.org",
});
return mockEvent;
}
export 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;
}
interface MockCallStoreType extends CallStore {
withActiveCall(): this;
isActiveCall: boolean;
call: ElementCall | null;
}
export class MockedCallStore extends EventEmitter {
public isActiveCall: boolean = false;
public constructor(public call: ElementCall | null) {
super();
}
public static create(call: ElementCall | null): MockCallStoreType {
return new MockedCallStore(call) as unknown as MockCallStoreType;
}
public withActiveCall(): this {
this.isActiveCall = true;
return this;
}
public getCall(): ElementCall | null {
return this.call;
}
public getActiveCall(): ElementCall | null {
if (this.isActiveCall) return this.getCall();
return null;
}
}
interface MockCallType extends ElementCall {
withOldestMembershipTs(ts: number): this;
withParticipants(participants: RoomMember[]): this;
}
export class MockedCall extends EventEmitter {
public participantMap = new Map<RoomMember, Set<string>>();
public createdTs: number = Date.now();
public static create(): MockCallType {
return new MockedCall() as unknown as MockCallType;
}
public withOldestMembershipTs(ts: number): this {
this.createdTs = ts;
return this;
}
public withParticipants(participants: RoomMember[]): this {
for (const participant of participants) {
this.participantMap.set(participant, new Set());
}
return this;
}
public get participants(): Map<RoomMember, Set<string>> {
return this.participantMap;
}
public get session(): MatrixRTCSession {
return {
getOldestMembership: (): CallMembership => {
return {
createdTs: () => this.createdTs,
} as CallMembership;
},
} as MatrixRTCSession;
}
}
@@ -0,0 +1,44 @@
/*
* 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 { CallType } from "@element-hq/web-shared-components";
import { EventType, RelationType, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { type GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile";
/**
* Find the call intent from a given rtc notification event.
* @param event Rtc notification event
*/
export function getIntentFromEvent(event: MatrixEvent): CallType {
const content = event.getContent<IRTCNotificationContent>();
const intentInContent = content["m.call.intent"];
switch (intentInContent) {
case "audio":
return CallType.Voice;
case "video":
default:
return CallType.Video;
}
}
/**
* Get all declined events that is related to the given rtc notification event.
* @param event rtc notification event
*/
export function getDeclinedEvents(
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;
}
return null;
}
@@ -0,0 +1,80 @@
/*
* 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.
*/
// @vitest-environment happy-dom
import { it, describe, expect, vi } from "vitest";
import { CallDirection, CallType } from "@element-hq/web-shared-components";
import { EventType, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { getMockedRtcDeclineEvent, getMockedRtcNotificationEvent } from "../../call-mocks";
import { formatTime } from "../../../../../../../DateUtils";
import { DmTombstoneCallTileViewModel } from "./DmTombstoneCallTileViewModel";
import { stubClient } from "../../../../../../../../test/test-utils";
describe("DmTombstoneCallTileViewModel", () => {
it("should compute correct state on decline", () => {
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000, "@alice:m.org");
const declineEvent = getMockedRtcDeclineEvent(mxEvent, "@alice:m.org");
const getRelationsForEvent = vi.fn();
const cli = stubClient();
const vm = new DmTombstoneCallTileViewModel({ mxEvent, getRelationsForEvent, cli });
// Without decline event, isCallDeclined = false
expect(vm.getSnapshot().isCallDeclined).toStrictEqual(false);
// Decline event comes through
getRelationsForEvent.mockReturnValue({
getRelations: () => [declineEvent],
});
mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", EventType.RTCDecline);
// Timestamp should be that of the decline event
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285416000)));
// Call should be declined
expect(vm.getSnapshot().isCallDeclined).toStrictEqual(true);
});
it("should compute voice intent in state", () => {
const mxEvent = getMockedRtcNotificationEvent("audio", 1752583130365, 1752583130365);
const getRelationsForEvent = vi.fn();
const cli = stubClient();
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
const { type } = vm.getSnapshot();
expect(type).toStrictEqual(CallType.Voice);
});
it("should compute video intent in state", () => {
const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365);
const getRelationsForEvent = vi.fn();
const cli = stubClient();
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
const { type } = vm.getSnapshot();
expect(type).toStrictEqual(CallType.Video);
});
describe("should compute callDirection", () => {
it("for outgoing", () => {
const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365, "@alice:m.org");
const getRelationsForEvent = vi.fn();
const cli = stubClient();
vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org");
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
expect(vm.getSnapshot().callDirection).toStrictEqual(CallDirection.Outgoing);
});
it("for incoming", () => {
const mxEvent = getMockedRtcNotificationEvent("video", 1752583130365, 1752583130365, "@bob:m.org");
const getRelationsForEvent = vi.fn();
const cli = stubClient();
vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org");
const vm = new DmTombstoneCallTileViewModel({ mxEvent, cli, getRelationsForEvent });
expect(vm.getSnapshot().callDirection).toStrictEqual(CallDirection.Incoming);
});
});
});
@@ -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 { CallDirection, type DmTombstoneCallTileViewSnapshot } from "@element-hq/web-shared-components";
import { type MatrixClient, type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../../../../../../../settings/SettingsStore";
import type { GetRelationsForEvent } from "../../../../../../../components/views/rooms/EventTile";
import { getTimeFromEvent } from "./common";
import {
RoomTombstoneCallTileViewModel,
type RoomTombstoneCallTileViewModelProps,
} from "./RoomTombstoneCallTileViewModel";
import { getDeclinedEvents, getIntentFromEvent } from "../../common";
export interface DmTombstoneCallTileViewModelProps extends RoomTombstoneCallTileViewModelProps {
/**
* Helper to fetch related events from a given event.
*/
getRelationsForEvent?: GetRelationsForEvent;
/**
* The {@link MatrixClient} object to access js-sdk API.
*/
cli: MatrixClient;
}
function generateSnapshot(props: DmTombstoneCallTileViewModelProps): {
snapshot: DmTombstoneCallTileViewSnapshot;
declineEvent: MatrixEvent | null;
} {
const { mxEvent, getRelationsForEvent, cli } = props;
const type = getIntentFromEvent(mxEvent);
// Find the mx-id of the user who started this call
const startedUserId = mxEvent.getSender();
if (!startedUserId) {
throw new Error("RTCNotification event has no sender associated with it!");
}
const callDirection = cli.getUserId() === startedUserId ? CallDirection.Outgoing : CallDirection.Incoming;
const declineEvent = getDeclinedEvents(mxEvent, getRelationsForEvent)?.[0] ?? null;
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const timestamp = getTimeFromEvent(declineEvent ?? mxEvent, showTwelveHour);
return { snapshot: { timestamp, type, callDirection, isCallDeclined: !!declineEvent }, declineEvent };
}
/**
* View model for a tombstone call in a DM.
*/
export class DmTombstoneCallTileViewModel extends RoomTombstoneCallTileViewModel<
DmTombstoneCallTileViewSnapshot,
DmTombstoneCallTileViewModelProps
> {
/**
* The decline event associated with this call, if any.
*/
private declineEvent: MatrixEvent | null;
public constructor(props: DmTombstoneCallTileViewModelProps) {
const { snapshot, declineEvent } = generateSnapshot(props);
super(props, snapshot);
this.declineEvent = declineEvent;
// When a relation is added to the event, recompute the state.
this.disposables.trackListener(props.mxEvent, MatrixEventEvent.RelationsCreated, () => {
const { declineEvent, snapshot } = generateSnapshot(props);
this.declineEvent = declineEvent;
this.snapshot.set(snapshot);
});
}
protected getTimestamp(showTwelveHour: boolean): string {
return getTimeFromEvent(this.declineEvent ?? this.props.mxEvent, showTwelveHour);
}
}
@@ -0,0 +1,46 @@
/*
* 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.
*/
// @vitest-environment happy-dom
import { it, describe, expect, vi } from "vitest";
import { getMockedRtcNotificationEvent } from "../../call-mocks";
import { RoomTombstoneCallTileViewModel } from "./RoomTombstoneCallTileViewModel";
import SettingsStore from "../../../../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../../../../settings/SettingLevel";
import { formatTime } from "../../../../../../../DateUtils";
describe("RoomTombstoneCallTileViewModel", () => {
it("should compute timestamp correctly", () => {
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000);
const vm = new RoomTombstoneCallTileViewModel({ mxEvent });
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000)));
});
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 RoomTombstoneCallTileViewModel({ mxEvent });
const { timestamp } = vm.getSnapshot();
expect(timestamp).toStrictEqual(formatTime(new Date(924285348000), true));
SettingsStore.reset();
});
it("should change timestamp format when setting is modified", async () => {
await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, false);
const mxEvent = getMockedRtcNotificationEvent("video", 924285348000, 924285348000);
const vm = new RoomTombstoneCallTileViewModel({ mxEvent });
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000)));
await SettingsStore.setValue("showTwelveHourTimestamps", null, SettingLevel.DEVICE, true);
await vi.waitFor(() => {
expect(vm.getSnapshot().timestamp).toStrictEqual(formatTime(new Date(924285348000), true));
});
SettingsStore.reset();
});
});
@@ -0,0 +1,69 @@
/*
* 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, type RoomTombstoneCallTileViewSnapshot } from "@element-hq/web-shared-components";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../../../../../../../settings/SettingsStore";
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 { getTimeFromEvent } from "./common";
export interface RoomTombstoneCallTileViewModelProps {
/**
* Event of type `org.matrix.msc4075.rtc.notification`.
*/
mxEvent: MatrixEvent;
/**
* Helper to fetch related events from a given event.
*/
getRelationsForEvent?: GetRelationsForEvent;
}
function generateSnapshot(event: MatrixEvent): RoomTombstoneCallTileViewSnapshot {
const showTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps");
const timestamp = getTimeFromEvent(event, showTwelveHour);
return { timestamp };
}
function isSettingsChangedPayload(payload: ActionPayload): payload is SettingUpdatedPayload {
return payload.action === Action.SettingUpdated;
}
/**
* View model for a tombstone call in a room.
*/
export class RoomTombstoneCallTileViewModel<
T extends RoomTombstoneCallTileViewSnapshot = RoomTombstoneCallTileViewSnapshot,
P extends RoomTombstoneCallTileViewModelProps = RoomTombstoneCallTileViewModelProps,
> extends BaseViewModel<T, P> {
public constructor(props: P, extraSnapshot: Partial<T> = {}) {
const snapshot = { ...generateSnapshot(props.mxEvent), ...extraSnapshot };
super(props, snapshot as T);
// 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);
});
}
private onAction = (payload: ActionPayload): void => {
if (!isSettingsChangedPayload(payload) || payload.settingName !== "showTwelveHourTimestamps") return;
const showTwelveHour = (payload.newValue as boolean) ?? false;
const timestamp = this.getTimestamp(showTwelveHour);
this.snapshot.merge({ timestamp } as Partial<T>);
};
protected getTimestamp(showTwelveHour: boolean): string {
return getTimeFromEvent(this.props.mxEvent, showTwelveHour);
}
}
@@ -0,0 +1,37 @@
/*
* 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 } from "matrix-js-sdk/src/matrix";
import { type IRTCNotificationContent } from "matrix-js-sdk/src/matrixrtc";
import { formatTime } from "../../../../../../../DateUtils";
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();
}
/**
* Get the time at which a call took place from a given rtc notification event.
* @param event The notification event
* @param showTwelveHour Whether the time is to be shown in 12 hour format
* @returns A formatted time string
*/
export function getTimeFromEvent(event: MatrixEvent, showTwelveHour: boolean): string {
const ts = getTs(event);
const date = new Date(ts);
const timestamp = formatTime(date, showTwelveHour);
return timestamp;
}