Ongoing call tiles - Implement the view-models and store (#34198)

* Add a common view snapshot type

This will contain the common snapshot values used by both dm ongoing
call tile view and room ongoing call tile view.

* Implement the common join button component

* Implement the call icon component

* Implement the common container component

* Support single digit minutes in existing clock component

* Implement the view for duration component

* Add static profile pics to be used in storybook tests

* Implement view for member avatar component

* Implement view for face pile component

* Implement RoomOngoingCallTileView

* Implement DmOngoingCallTileView

* Support the new tiles in RootTileView

* Export all the views

* Update all the screenshots

* Implement view model for duration view

* Implement vm for face pile view

* Implement vm for member avatar view

* Expose call store via the sdk context

* Add method to get all calls in store

* Add store to track latest notification event per room

* Also expose new store via sdk context

* Create a base vm for both tiles

* Implement the room ongoing call tile vm

* Implement the dm ongoing call tile vm

* Support new tile vms in the root tile vm

* Rename getCallInRoom -> getCallOrThrow

* Change confusing comment in test

* Check existing events first and use decryption promise

* Clarify recompute vs update in code comment

* Use vi.mocked instead of ts-ignore

* Change type of size to number

* Import only the type

* Pass size as number instead of string

* Check if call is jistsi call in root tile

* Add more tests

* Rename isCallIgnored -> isCallDeclined

* Add a comment about the 10px margin

* Add comments to explain css heights and widths
This commit is contained in:
R Midhun Suresh
2026-07-13 14:52:16 +00:00
committed by GitHub
parent a6c8a14b74
commit c4606c19f8
20 changed files with 1449 additions and 9 deletions
@@ -0,0 +1,49 @@
/*
* 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 } from "vitest";
import { mkRoomMember, stubClient } from "../../../../test/test-utils";
import { FacePileViewModel } from "./FacePileViewModel";
describe("FacePileViewModel", () => {
it("should compute the correct initial state", () => {
const cli = stubClient();
const members = [
mkRoomMember("!my-room:m.org", "@alice:m.org"),
mkRoomMember("!my-room:m.org", "@bob:m.org"),
mkRoomMember("!my-room:m.org", "@jack:m.org"),
];
const vm = new FacePileViewModel({
cli,
members,
size: 20,
});
expect(vm.getSnapshot().memberAvatarViewModels).toHaveLength(3);
});
it("should update on updateMembers()", () => {
const cli = stubClient();
const members = [mkRoomMember("!my-room:m.org", "@alice:m.org"), mkRoomMember("!my-room:m.org", "@bob:m.org")];
const vm = new FacePileViewModel({
cli,
members,
size: 20,
});
expect(vm.getSnapshot().memberAvatarViewModels).toHaveLength(2);
vm.updateMembers([
mkRoomMember("!my-room:m.org", "@alice:m.org"),
mkRoomMember("!my-room:m.org", "@bob:m.org"),
mkRoomMember("!my-room:m.org", "@jack:m.org"),
]);
expect(vm.getSnapshot().memberAvatarViewModels).toHaveLength(3);
});
});
@@ -0,0 +1,63 @@
/*
* 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 FacePileViewSnapshot } from "@element-hq/web-shared-components";
import { type MatrixClient, type RoomMember } from "matrix-js-sdk/src/matrix";
import { MemberAvatarViewModel } from "./MemberAvatarViewModel";
interface Props {
/**
* The size of each avatar in the face-pile in pixels, eg: 20.
*/
size: number;
/**
* List of room-members from which to render the face-pile..
*/
members: RoomMember[];
/**
* MatrixClient object to access js-sdk.
*/
cli: MatrixClient;
}
/**
* View model for rendering face-piles.
*/
export class FacePileViewModel extends BaseViewModel<FacePileViewSnapshot, Props> {
private viewModelMap: Set<MemberAvatarViewModel> = new Set();
public constructor(props: Props) {
super(props, { memberAvatarViewModels: [] });
this.computeSnapshot();
}
private computeSnapshot(): void {
for (const vm of this.viewModelMap.values()) {
vm.dispose();
}
this.viewModelMap = new Set();
const members = this.props.members.slice(0, 3);
for (const member of members) {
const vm = this.disposables.track(
new MemberAvatarViewModel({ size: this.props.size, member, cli: this.props.cli }),
);
this.viewModelMap.add(vm);
}
this.snapshot.set({ memberAvatarViewModels: Array.from(this.viewModelMap.values()) });
}
/**
* Update the room-members from which the face-pile is rendered.
* @param newMembers The list of new room members
*/
public updateMembers(newMembers: RoomMember[]): void {
this.props.members = newMembers;
this.computeSnapshot();
}
}
@@ -0,0 +1,78 @@
/*
* 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 { EventTimeline, type RoomState, RoomStateEvent } from "matrix-js-sdk/src/matrix";
import { mkEvent, mkRoom, mkRoomMember, stubClient } from "../../../../test/test-utils";
import { MemberAvatarViewModel } from "./MemberAvatarViewModel";
vi.mock(import("../../../customisations/Media"), () => {
return {
mediaFromMxc: vi.fn().mockReturnValue({
getThumbnailOfSourceHttp: () => "avatar-url",
}),
};
});
describe("MemberAvatarViewModel", () => {
it("should compute the correct initial snapshot", () => {
const cli = stubClient();
const member = mkRoomMember("!my-room:m.org", "@alice:m.org");
member.getMxcAvatarUrl = () => "avatar-mxc-url";
member.name = "Alice";
const vm = new MemberAvatarViewModel({ cli, member, size: 20 });
const snapshot = vm.getSnapshot();
expect(snapshot.id).toStrictEqual("@alice:m.org");
expect(snapshot.title).toStrictEqual("@alice:m.org");
expect(snapshot.name).toStrictEqual("Alice");
expect(snapshot.size).toStrictEqual("20px");
expect(snapshot.url).toStrictEqual("avatar-url");
});
it("should update state", () => {
const cli = stubClient();
const room = mkRoom(cli, "!my-room:m.org");
cli.getRoom = () => room;
const member = mkRoomMember("!my-room:m.org", "@alice:m.org");
member.name = "Alice";
// The name is initially Alice
const vm = new MemberAvatarViewModel({ cli, member, size: 20 });
expect(vm.getSnapshot().name).toStrictEqual("Alice");
// On room event, name should update to Bob
member.name = "Bob";
const event = mkEvent({
type: "m.room.member",
user: "@alice.m.org",
content: {
displayname: "Bob",
},
});
vi.spyOn(room, "getLiveTimeline").mockImplementation(() => {
return {
getState: (): RoomState => {
return {
mayClientSendStateEvent: () => true,
} as unknown as RoomState;
},
} as unknown as EventTimeline;
});
vi.mocked(room).emit(
RoomStateEvent.Members,
event,
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
member,
);
expect(vm.getSnapshot().name).toStrictEqual("Bob");
});
});
@@ -0,0 +1,73 @@
/*
* 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 MemberAvatarViewSnapshot } from "@element-hq/web-shared-components";
import { type MatrixClient, RoomStateEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { mediaFromMxc } from "../../../customisations/Media";
interface Props {
/**
* The size of the avatar in pixels, eg: 20.
*/
size: number;
/**
* The room member for this avatar.
*/
member: RoomMember;
/**
* MatrixClient object to access js-sdk.
*/
cli: MatrixClient;
}
function computeSnapshot(props: Props): MemberAvatarViewSnapshot {
const size = props.size;
const member = props.member;
const id = member.userId;
const name = member.name ?? member.userId;
const title =
UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId ?? "", {
roomId: member.roomId ?? "",
}) ?? member.userId;
let url: string | undefined;
if (member.getMxcAvatarUrl()) {
url =
mediaFromMxc(member.getMxcAvatarUrl() ?? "", props.cli).getThumbnailOfSourceHttp(size, size, "crop") ??
undefined;
}
return {
name,
id,
url,
size: `${size}px`,
title,
};
}
/**
* A view-model for rendering the avatar for a given room member.
*/
export class MemberAvatarViewModel extends BaseViewModel<MemberAvatarViewSnapshot, Props> {
public constructor(props: Props) {
super(props, computeSnapshot(props));
const roomId = props.member.roomId;
const room = props.cli.getRoom(roomId);
if (room) {
this.disposables.trackListener(room, RoomStateEvent.Members, this.onUpdate as (...args: unknown[]) => void);
}
}
private onUpdate = (event: unknown, state: unknown, member: RoomMember): void => {
if (member.userId === this.props.member.userId) {
const newSnapshot = computeSnapshot(this.props);
this.snapshot.set(newSnapshot);
}
};
}
+17
View File
@@ -30,6 +30,8 @@ import { Action } from "../dispatcher/actions.ts";
import { type OnLoggedInPayload } from "../dispatcher/payloads/OnLoggedInPayload.ts";
import Notifier from "../Notifier.ts";
import SettingController from "../settings/controllers/SettingController.ts";
import { CallStore } from "../stores/CallStore";
import { LatestRtcNotificationEventStore } from "../stores/LatestRtcNotificationEventStore";
/**
* A class which (mostly) lazily initialises stores as and when they are requested, ensuring they remain
@@ -72,6 +74,8 @@ export class SDKContextClass {
protected _ResizeNotifier?: ResizeNotifier;
protected _MultiRoomViewStore?: MultiRoomViewStore;
protected _Notifier?: Notifier;
protected _CallStore?: CallStore;
protected _LatestRtcNotificationEventStore?: LatestRtcNotificationEventStore;
public constructor() {
SettingController.sdkContext = this;
@@ -203,6 +207,19 @@ export class SDKContextClass {
return this._Notifier;
}
public get callStore(): CallStore {
this._CallStore ??= CallStore.instance;
return this._CallStore;
}
public get latestRtcNotificationEventStore(): LatestRtcNotificationEventStore {
if (!this._LatestRtcNotificationEventStore) {
this._LatestRtcNotificationEventStore = new LatestRtcNotificationEventStore(this.callStore);
this._LatestRtcNotificationEventStore.start();
}
return this._LatestRtcNotificationEventStore;
}
public onLoggedOut(): void {
this._UserProfilesStore = undefined;
this._client = undefined;
+5 -1
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useEffect } from "react";
import React, { type JSX, useContext, useEffect } from "react";
import {
type MatrixEvent,
EventType,
@@ -57,6 +57,7 @@ import { HiddenBodyViewModel } from "../viewmodels/room/timeline/event-tile/body
import { ViewSourceEventViewModel } from "../viewmodels/room/timeline/event-tile/body/ViewSourceEventViewModel";
import { ElementCallEventType } from "../call-types";
import { RootCallTileViewModel } from "../viewmodels/room/timeline/event-tile/call/RootCallTileViewModel";
import { SDKContext } from "../contexts/SDKContext";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps extends Pick<
@@ -188,6 +189,7 @@ function RoomAvatarEventWrappedView({ mxEvent, ref }: IBodyProps): JSX.Element {
const RoomAvatarEventFactory: Factory = (ref, props) => <RoomAvatarEventWrappedView ref={ref} {...props} />;
function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element {
const sdkContext = useContext(SDKContext);
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(
() =>
@@ -195,6 +197,8 @@ function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProp
mxEvent,
getRelationsForEvent,
cli,
callStore: sdkContext.callStore,
latestRtcNotificationEventStore: sdkContext.latestRtcNotificationEventStore,
}),
);
return <RootCallTileView vm={vm} />;
+8
View File
@@ -210,6 +210,14 @@ export class CallStore extends AsyncStoreWithClient<EmptyObject> {
return call !== null && this.connectedCalls.has(call) ? call : null;
}
/**
* Get all the ongoing calls
* @returns Map from room-id to the ongoing call in that room
*/
public getAllCalls(): Map<string, Call> {
return this.calls;
}
private onWidgets = (roomId: string | null): void => {
if (!this.matrixClient) return;
if (roomId === null) {
@@ -0,0 +1,167 @@
/*
* 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 { describe, it, expect, vi } from "vitest";
import { type EventTimeline, EventType, RoomEvent } from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "stream";
import { mkEvent, mkRoom, mkRoomMember, stubClient } from "../../test/test-utils";
import { CallStoreEvent, type CallStore } from "./CallStore";
import { LatestRtcNotificationEventStore } from "./LatestRtcNotificationEventStore";
import { type ElementCall, type Call } from "../models/Call";
describe("LatestRtcNotificationEventStore", () => {
it("should populate map from timeline on start", () => {
const cli = stubClient();
const event1 = mkEvent({
type: EventType.RTCNotification,
user: "@alice:m.org",
content: {},
event: true,
id: "event-1",
});
const room1 = mkRoom(cli, "!my-room1:m.org");
vi.spyOn(room1, "getLiveTimeline").mockImplementation(() => {
return {
getEvents: () => {
return [event1];
},
} as unknown as EventTimeline;
});
const event2 = mkEvent({
type: EventType.RTCNotification,
user: "@bob:m.org",
content: {},
event: true,
id: "event-2",
});
const room2 = mkRoom(cli, "!my-room2:m.org");
vi.spyOn(room2, "getLiveTimeline").mockImplementation(() => {
return {
getEvents: () => {
return [event2];
},
} as unknown as EventTimeline;
});
vi.spyOn(cli, "getRoom").mockImplementation((roomId) => (roomId === "!my-room1:m.org" ? room1 : room2));
const callStore = new EventEmitter() as unknown as CallStore;
callStore.getAllCalls = () => {
return new Map([
["!my-room1:m.org", undefined],
["!my-room2:m.org", undefined],
]) as unknown as Map<string, Call>;
};
const store = new LatestRtcNotificationEventStore(callStore);
vi.spyOn(store, "matrixClient", "get").mockReturnValue(cli);
store.start();
vi.waitFor(() => {
expect(store.getLatestEventId("!my-room1:m.org")).toStrictEqual("event-1");
expect(store.getLatestEventId("!my-room2:m.org")).toStrictEqual("event-2");
});
});
it("should wait for rtc notification event when a call begins", () => {
const cli = stubClient();
// This is the last notification event in the timeline
const oldNotificationEvent = mkEvent({
type: EventType.RTCNotification,
user: "@alice:m.org",
content: {
"m.relates_to": {
event_id: "$GOr0aimJwcQcPV4F20IEhBCpandNPMEfhWYfpcK3VeE",
rel_type: "m.reference",
},
},
event: true,
id: "event-1",
});
// This is the notification event corresponding to the ongoing call which hasn't come in yet
const newNotificationEvent = mkEvent({
type: EventType.RTCNotification,
user: "@alice:m.org",
content: {
"m.relates_to": {
event_id: "call-membership-1",
rel_type: "m.reference",
},
},
event: true,
id: "event-2",
});
// The call membership event associated with newNotificationEvent
const callMembershipEvent = mkEvent({
type: EventType.GroupCallMemberPrefix,
user: "@alice:m.org",
content: {},
event: true,
id: "call-membership-1",
});
// The room where this call takes place
const room = mkRoom(cli, "!my-room1:m.org");
vi.spyOn(cli, "getRoom").mockImplementation(() => room);
// The current timeline in that room
const timeline = [oldNotificationEvent];
vi.spyOn(room, "getLiveTimeline").mockImplementation(() => {
return {
getEvents: () => {
return timeline;
},
} as unknown as EventTimeline;
});
const call = new EventEmitter();
// @ts-ignore
call.participants = new Map([[mkRoomMember("!my-room1:m.org", "@alice:m.org"), new Set()]]);
// @ts-ignore
call.session = {
getOldestMembership: () => {
return {
eventId: callMembershipEvent.getId(),
};
},
};
const callStore = new EventEmitter() as unknown as CallStore;
callStore.getAllCalls = () => {
return new Map([]) as unknown as Map<string, Call>;
};
callStore.getCall = () => call as unknown as ElementCall;
const store = new LatestRtcNotificationEventStore(callStore);
vi.spyOn(store, "matrixClient", "get").mockReturnValue(cli);
store.start();
// No calls in the room yet
expect(store.getLatestEventId("!my-room1:m.org")).toBeUndefined();
// Let's say a new call starts
callStore.emit(CallStoreEvent.Call, call, room);
// The correct notification event hasn't come in yet, so still undefined.
expect(store.getLatestEventId("!my-room1:m.org")).toBeUndefined();
// Notification event comes in
room.emit(RoomEvent.Timeline, newNotificationEvent);
// Should update the event id in store
vi.waitFor(() => {
expect(store.getLatestEventId("!my-room1:m.org")).toStrictEqual("event-2");
});
});
});
@@ -0,0 +1,176 @@
/*
* 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 EmptyObject, EventType, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
import { AsyncStoreWithClient } from "./AsyncStoreWithClient";
import defaultDispatcher from "../dispatcher/dispatcher";
import { type CallStore, CallStoreEvent } from "./CallStore";
import { CallEvent, type ElementCall } from "../models/Call";
/**
* The event that {@link LatestRtcNotificationEventStore} emits when the latest event id changes.
*/
export const LatestRtcNotificationEventUpdate = "rtc_notification_updated";
/**
* This store tracks the event-id of the {@link EventType.RTCNotification} event that
* is related to an ongoing call.
* This is required because the rtc notification arrives after the call has started.
* Without this logic, we wouldn't know whether the last rtc notification tile in the
* timeline is related to an ongoing call or if it is related to some other rtc notification
* event that is yet to come through sync.
*/
export class LatestRtcNotificationEventStore extends AsyncStoreWithClient<EmptyObject> {
private eventIdMap = new Map<string, string>();
public constructor(private readonly callStore: CallStore) {
super(defaultDispatcher);
}
/**
* Get the event id of the latest rtc notification event corresponding to an ongoing call.
* @param roomId The id of the room where the call is taking place
* @returns event id or undefined
*/
public getLatestEventId(roomId: string): string | undefined {
return this.eventIdMap.get(roomId);
}
private async populateMap(): Promise<void> {
if (!this.matrixClient) return;
// 1. Get all the calls
const callMap = this.callStore.getAllCalls();
// 2. Add the calls to our map
for (const roomId of callMap.keys()) {
const room = this.getRoom(roomId);
const eventId = (await getLastRtcNotificationEvent(room))?.getId();
if (eventId) {
this.eventIdMap.set(roomId, eventId);
// 3. Emit event so that the call tile can re-render
this.emit(LatestRtcNotificationEventUpdate, roomId, eventId);
}
}
}
private getRoom(roomId: string): Room {
if (!this.matrixClient) {
throw new Error("LatestRtcNotificationEventStore: this.matrixClient is undefined.");
}
const room = this.matrixClient.getRoom(roomId);
if (!room) throw new Error(`No room associated with room-id ${roomId}`);
return room;
}
protected async onReady(): Promise<void> {
this.callStore.on(CallStoreEvent.Call, this.onCallStoreEvent);
this.populateMap();
}
protected async onNotReady(): Promise<void> {
this.callStore.off(CallStoreEvent.Call, this.onCallStoreEvent);
this.eventIdMap.clear();
}
protected async onAction(): Promise<void> {
// nothing to do
}
private onCallStoreEvent = async (_: unknown, roomId: string): Promise<void> => {
if (!this.matrixClient) return;
const call = this.callStore.getCall(roomId) as ElementCall;
if (!call) {
// If there's no longer a call in this room, remove entry from map
this.eventIdMap.delete(roomId);
} else if (call && !this.eventIdMap.has(roomId)) {
// If there's a call, find the event id and add it to the map.
if (call.participants.size) {
this.findAndSetNotificationEvent(call, roomId);
} else {
// This call just started but the participants haven't been added yet.
// So wait until participants are added and then compute the event id.
call.once(CallEvent.Participants, () => {
this.findAndSetNotificationEvent(call, roomId);
});
}
}
};
private async findAndSetNotificationEvent(call: ElementCall, roomId: string): Promise<void> {
const room = this.getRoom(roomId);
// Since we're running this logic as the call just started, this membership
// event corresponds to the person who started the call.
// The rtc notification event corresponding to this ongoing call will have
// a reference relation to this membership event.
const callMembershipEventId = call.session.getOldestMembership()?.eventId;
if (callMembershipEventId) {
const eventId = await getNotificationEventId(room, callMembershipEventId);
this.eventIdMap.set(roomId, eventId);
this.emit(LatestRtcNotificationEventUpdate, roomId, eventId);
}
}
}
/**
* Returns the first rtc notification event from the end of the timeline
*/
async function getLastRtcNotificationEvent(room: Room): Promise<MatrixEvent | undefined> {
const events = room.getLiveTimeline().getEvents();
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
// Wait for decryption if necessary
await event.getDecryptionPromise();
if (event.getType() === EventType.RTCNotification) {
return event;
}
}
}
/**
* Get the event id of the rtc notification event that has a relation to a given call membership event.
* @param room The room in which this call is taking place
* @param callMemberEventId The event id of the call membership event
* @returns A promise that resolves to the event-id of the notification event.
*/
async function getNotificationEventId(room: Room, callMemberEventId: string): Promise<string> {
// We might already have the notification event in the timeline, so check that first.
const event = await getLastRtcNotificationEvent(room);
const relatedEventId = event?.getRelation()?.event_id;
const eventId = event?.getId();
if (relatedEventId === callMemberEventId && eventId) return eventId;
const { promise, resolve, reject } = Promise.withResolvers<string>();
// This callback will run with new events that are added to the timeline.
// We'll run some logic in the callback to see if we're receiving any
// new notification event that corresponds to the call membership event.
const onNewEvent = async (event: MatrixEvent): Promise<void> => {
// Wait for decryption if necessary
await event.getDecryptionPromise();
const type = event.getType();
const relatedEventId = event.getRelation()?.event_id;
const eventId = event.getId();
if (type === EventType.RTCNotification && relatedEventId === callMemberEventId && eventId) {
// Remove event listener
room.off(RoomEvent.Timeline, onNewEvent);
resolve(eventId);
}
};
room.on(RoomEvent.Timeline, onNewEvent);
// We won't wait for more than ten seconds for the notification event to come through.
setTimeout(() => {
reject(new Error("Timeout waiting for rtc notification event"));
}, 10000);
return await promise;
}
@@ -9,10 +9,15 @@
import { it, describe, expect, vi } from "vitest";
import { type EventTimeline, EventType, type MatrixEvent, type RoomState } from "matrix-js-sdk/src/matrix";
import { EventEmitter } from "node:stream";
import { mkEvent, mkMessage, mkRoomMember, mkStubRoom, stubClient } from "../../../../../../test/test-utils";
import { getMockedRtcNotificationEvent, MockedCall, MockedCallStore } from "./call-mocks";
import { RootCallTileViewModel } from "./RootCallTileViewModel";
import {
LatestRtcNotificationEventUpdate,
type LatestRtcNotificationEventStore,
} from "../../../../../stores/LatestRtcNotificationEventStore";
function getEvents(): MatrixEvent[] {
const message1 = mkMessage({
@@ -80,21 +85,65 @@ function getMocked(userIds: string[]) {
} as unknown as EventTimeline;
});
return { callStore, cli, mxEvent, call };
const latestRtcNotificationEventStore = new EventEmitter() as unknown as LatestRtcNotificationEventStore;
latestRtcNotificationEventStore.getLatestEventId = () => undefined;
return { callStore, latestRtcNotificationEventStore, cli, mxEvent, call };
}
describe("RootCallTileViewModel", () => {
it("computes correct tileType for ongoing call in DM", () => {
const { callStore, cli, mxEvent, latestRtcNotificationEventStore } = getMocked(["@alice:m.org", "@bob:m.org"]);
latestRtcNotificationEventStore.getLatestEventId = () => "new-event";
const vm = new RootCallTileViewModel({ latestRtcNotificationEventStore, callStore, cli, mxEvent });
expect(vm.getSnapshot().tileType).toStrictEqual("ongoing-call-dm");
});
it("computes correct tileType for ongoing call in Room", () => {
const { callStore, cli, mxEvent, latestRtcNotificationEventStore } = getMocked([
"@alice:m.org",
"@bob:m.org",
"@jack:m.org",
]);
latestRtcNotificationEventStore.getLatestEventId = () => "new-event";
const vm = new RootCallTileViewModel({ latestRtcNotificationEventStore, callStore, cli, mxEvent });
expect(vm.getSnapshot().tileType).toStrictEqual("ongoing-call-room");
});
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 });
// When there's an ongoing call
const { callStore, cli, mxEvent, latestRtcNotificationEventStore } = getMocked(["@alice:m.org", "@bob:m.org"]);
const vm = new RootCallTileViewModel({ latestRtcNotificationEventStore, callStore, 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 });
// When there's an ongoing call
const { callStore, cli, mxEvent, latestRtcNotificationEventStore } = getMocked([
"@alice:m.org",
"@bob:m.org",
"@jack:m.org",
]);
const vm = new RootCallTileViewModel({ latestRtcNotificationEventStore, callStore, cli, mxEvent });
expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-room");
});
it("recomputes snapshot on event from LatestRtcNotificationEventUpdate", () => {
// When there's an ongoing call
const { callStore, cli, mxEvent, latestRtcNotificationEventStore } = getMocked([
"@alice:m.org",
"@bob:m.org",
"@jack:m.org",
]);
const vm = new RootCallTileViewModel({ latestRtcNotificationEventStore, callStore, cli, mxEvent });
expect(vm.getSnapshot().tileType).toStrictEqual("tombstone-call-room");
// Tile type should update on event
latestRtcNotificationEventStore.getLatestEventId = () => "new-event";
latestRtcNotificationEventStore.emit(LatestRtcNotificationEventUpdate, "!my-room:m.org", "new-event");
expect(vm.getSnapshot().tileType).toStrictEqual("ongoing-call-room");
});
});
@@ -6,11 +6,19 @@
*/
import { type MatrixClient, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { BaseViewModel, type RootCallTileViewSnapshot } from "@element-hq/web-shared-components";
import { BaseViewModel, type ViewModel, type RootCallTileViewSnapshot } from "@element-hq/web-shared-components";
import type { GetRelationsForEvent } from "../../../../../components/views/rooms/EventTile";
import { type CallStore, CallStoreEvent } from "../../../../../stores/CallStore";
import { RoomOngoingCallTileViewModel } from "./tiles/ongoing/RoomOngoingCallTileViewModel";
import { DmOngoingCallTileViewModel } from "./tiles/ongoing/DmOngoingCallTileViewModel";
import { DmTombstoneCallTileViewModel } from "./tiles/tombstone/DmTombstoneCallTileViewModel";
import { RoomTombstoneCallTileViewModel } from "./tiles/tombstone/RoomTombstoneCallTileViewModel";
import {
LatestRtcNotificationEventUpdate,
type LatestRtcNotificationEventStore,
} from "../../../../../stores/LatestRtcNotificationEventStore";
import { JitsiCall } from "../../../../../models/Call";
interface Props {
/**
@@ -27,6 +35,16 @@ interface Props {
* The {@link MatrixClient} object to access js-sdk API.
*/
cli: MatrixClient;
/**
* {@link CallStore} to access calls in a room.
*/
callStore: CallStore;
/**
* {@link LatestRtcNotificationEventStore} to track the latest notification event id.
*/
latestRtcNotificationEventStore: LatestRtcNotificationEventStore;
}
function computeSnapshot(props: Props): RootCallTileViewSnapshot {
@@ -39,9 +57,47 @@ function computeSnapshot(props: Props): RootCallTileViewSnapshot {
const room = cli.getRoom(roomId);
if (!room) throw new Error(`No room with id ${roomId}`);
const lastId = props.latestRtcNotificationEventStore.getLatestEventId(roomId);
const isLastNotificationEvent = lastId === notificationEvent.getId();
const isLocalEvent = notificationEvent.getId()?.startsWith("~");
// Check if there's an ongoing call
const call = props.callStore.getCall(roomId);
const hasOngoingCall = !!call && !(call instanceof JitsiCall);
// This is the same logic used for hiding/showing the voice call button.
const isDmRoom = room.getMembers().length <= 2;
/**
* We know we should render the ongoing tile if:
* - There's an ongoing call in this room
* - This is the last call tile in the room or a local call tile.
*/
if ((isLastNotificationEvent || isLocalEvent) && hasOngoingCall) {
if (isDmRoom) {
return {
tileType: "ongoing-call-dm",
tileViewModel: new DmOngoingCallTileViewModel({
mxEvent: notificationEvent,
roomId,
getRelationsForEvent: props.getRelationsForEvent,
callStore: props.callStore,
cli: props.cli,
}),
};
}
return {
tileType: "ongoing-call-room",
tileViewModel: new RoomOngoingCallTileViewModel({
roomId,
mxEvent: notificationEvent,
getRelationsForEvent: props.getRelationsForEvent,
callStore: props.callStore,
cli: props.cli,
}),
};
}
if (isDmRoom) {
return {
tileType: "tombstone-call-dm",
@@ -65,5 +121,53 @@ export class RootCallTileViewModel extends BaseViewModel<RootCallTileViewSnapsho
public constructor(props: Props) {
const snapshot = computeSnapshot(props);
super(props, snapshot);
this.trackViewModel(snapshot.tileViewModel);
this.addListener();
}
private addListener(): void {
// Recompute the state on changes to the call in this room
this.disposables.trackListener(
this.props.callStore,
CallStoreEvent.Call,
this.onCallStoreEvent as (...args: unknown[]) => void,
);
// Recompute the state when the latest rtc notification event in this room changes
this.disposables.trackListener(this.props.latestRtcNotificationEventStore, LatestRtcNotificationEventUpdate, ((
roomId: string,
eventId: string,
) => {
if (roomId === this.props.mxEvent.getRoomId() && eventId === this.props.mxEvent.getId()) {
this.recomputeSnapshot();
}
}) as (...args: unknown[]) => void);
}
private onCallStoreEvent = (_: unknown, roomId: string): void => {
if (roomId === this.props.mxEvent.getRoomId()) {
this.recomputeSnapshot();
}
};
private recomputeSnapshot(): void {
const snapshot = computeSnapshot(this.props);
const currentTileType = this.getSnapshot().tileType;
if (currentTileType === snapshot.tileType) {
/**
* We're already rendering the correct vm if the tile type did not change.
* So dispose thew new vm and return.
*/
(snapshot.tileViewModel as BaseViewModel<unknown, unknown>).dispose();
return;
}
this.trackViewModel(snapshot.tileViewModel);
this.snapshot.set(snapshot);
}
private trackViewModel(vm: ViewModel<unknown>): void {
// ViewModel type has no dispose method, so this needs a type assertion.
this.disposables.track(vm as BaseViewModel<unknown, unknown>);
}
}
@@ -8,7 +8,7 @@
import { EventEmitter } from "events";
import { type RoomMember, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import { mkEvent } from "../../../../../../test/test-utils";
import { mkEvent, mkRoomMember } 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";
@@ -49,6 +49,12 @@ export function getMockedRtcDeclineEvent(rtcNotificationEvent: MatrixEvent, send
return mockEvent;
}
export function getMockedMember(roomId: string, userId: string, name: string): RoomMember {
const member = mkRoomMember(roomId, userId);
member.name = name;
return member;
}
interface MockCallStoreType extends CallStore {
withActiveCall(): this;
isActiveCall: boolean;
@@ -0,0 +1,159 @@
/*
* 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 { BaseOngoingCallViewModel } from "./BaseOngoingCallTileViewModel";
import { mkRoom, stubClient } from "../../../../../../../../test/test-utils";
import { CallEvent } from "../../../../../../../models/Call";
import type { RootCallTileViewModel } from "../../RootCallTileViewModel";
import { getMockedMember, getMockedRtcNotificationEvent, MockedCall, MockedCallStore } from "../../call-mocks";
import type { EventTimeline, RoomState } from "matrix-js-sdk/src/matrix";
import { placeCall } from "../../../../../../../utils/room/placeCall";
import { PlatformCallType } from "../../../../../../../hooks/room/useRoomCall";
/**
* There's a nasty circular dependency in useRoomCall so that we end up with:
* BaseOngoingCallViewModel -> useRoomCall -> ... -> EventTileFactory
* -> RootCallTileViewModel -> RoomOngoingCallTileViewModel -> BaseOngoingCallViewModel
*/
//@ts-ignore
vi.mock(import("../../RootCallTileViewModel"), () => {
return {
RootCallTileViewModel: class {} as unknown as RootCallTileViewModel,
};
});
vi.mock(import("../../../../../../../utils/room/placeCall"), () => {
return {
placeCall: vi.fn(),
};
});
const roomId = "!my-room:m.org";
describe("BaseOngoingCallViewModel", () => {
describe("should compute the correct snapshot", () => {
it("startedByDisplayName", () => {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create();
const callStore = MockedCallStore.create(call);
const vm = new BaseOngoingCallViewModel({ mxEvent, cli, callStore, roomId });
expect(vm.getSnapshot().startedByDisplayName).toStrictEqual("Alice");
});
it("isJoined", () => {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const bob = getMockedMember(roomId, "@bob:m.org", "Bob");
const call = MockedCall.create().withParticipants([bob]);
const callStore = MockedCallStore.create(call);
// Alice hasn't joined the call yet
const vm = new BaseOngoingCallViewModel({ mxEvent, cli, callStore, roomId });
expect(vm.getSnapshot().isJoined).toStrictEqual(false);
// Alice has joined call
callStore.withActiveCall();
call.withParticipants([mxEvent.sender, bob]);
call.emit(CallEvent.Participants, call.participants, new Map());
expect(vm.getSnapshot().isJoined).toStrictEqual(true);
});
it("callDirection", () => {
const cli = stubClient();
vi.spyOn(cli, "getUserId").mockReturnValue("@bob:m.org");
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100, "@alice:m.org");
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create();
const callStore = MockedCallStore.create(call);
const vm1 = new BaseOngoingCallViewModel({ mxEvent, cli, callStore, roomId });
expect(vm1.getSnapshot().callDirection).toStrictEqual(CallDirection.Incoming);
vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org");
const vm2 = new BaseOngoingCallViewModel({ mxEvent, cli, callStore, roomId });
expect(vm2.getSnapshot().callDirection).toStrictEqual(CallDirection.Outgoing);
});
it("callHasOtherParticipants", () => {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create().withParticipants([mxEvent.sender]);
const callStore = MockedCallStore.create(call);
// Call has no other participants other than alice
const vm = new BaseOngoingCallViewModel({ mxEvent, cli, callStore, roomId });
expect(vm.getSnapshot().callHasOtherParticipants).toStrictEqual(false);
// Let's say others join
const bob = getMockedMember(roomId, "@bob:m.org", "Bob");
const james = getMockedMember(roomId, "@james:m.org", "James");
call.withParticipants([mxEvent.sender, bob, james]);
call.emit(CallEvent.Participants, call.participants, new Map());
expect(vm.getSnapshot().callHasOtherParticipants).toStrictEqual(true);
});
it("isJoinable", () => {
const cli = stubClient();
const room = mkRoom(cli, roomId);
// @ts-ignore
room.getLiveTimeline = (): EventTimeline => {
return {
getState: (): RoomState => {
return {
mayClientSendStateEvent: () => true,
} as unknown as RoomState;
},
} as unknown as EventTimeline;
};
cli.getRoom = () => room;
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create();
const callStore = MockedCallStore.create(call);
const vm = new BaseOngoingCallViewModel({ mxEvent, cli, callStore, roomId });
expect(vm.getSnapshot().isJoinable).toStrictEqual(true);
});
});
it("should join call on join()", () => {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("video", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create().withParticipants([mxEvent.sender]);
const callStore = MockedCallStore.create(call);
const vm = new BaseOngoingCallViewModel({ mxEvent, cli, callStore, roomId });
vm.join();
const [room, callType, platformCallType] = vi.mocked(placeCall).mock.calls[0];
expect(room.roomId).toStrictEqual(roomId);
expect(callType).toStrictEqual(CallType.Video);
expect(platformCallType).toStrictEqual(PlatformCallType.ElementCall);
});
});
@@ -0,0 +1,184 @@
/*
* 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,
CallDirection,
CallType as SharedComponentsCallType,
type CommonOngoingCallTileViewSnapshot,
} from "@element-hq/web-shared-components";
import {
EventTimeline,
EventType,
type RoomMember,
type MatrixEvent,
type MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { type CallStore } from "../../../../../../../stores/CallStore";
import { MemberAvatarViewModel } from "../../../../../../../components/viewmodels/avatars/MemberAvatarViewModel";
import { FacePileViewModel } from "../../../../../../../components/viewmodels/avatars/FacePileViewModel";
import { CallEvent, type ElementCall } from "../../../../../../../models/Call";
import { placeCall } from "../../../../../../../utils/room/placeCall";
import { PlatformCallType } from "../../../../../../../hooks/room/useRoomCall";
import { type GetRelationsForEvent } from "../../../../../../../components/views/rooms/EventTile";
import { getIntentFromEvent } from "../../common";
import { DurationViewModel } from "./components/DurationViewModel";
export interface Props {
/**
* The id of the room.
*/
roomId: string;
/**
* 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;
/**
* {@link CallStore} to access calls in a room.
*/
callStore: CallStore;
}
function getCallOrThrow(store: CallStore, roomId: string): ElementCall {
const call = store.getCall(roomId);
if (!call) {
throw new Error(`No call in room ${roomId}`);
}
return call as ElementCall;
}
/**
* Whether this call has participants other than who started the call.
*/
function doesCallHaveOtherParticipants(notificationEvent: MatrixEvent, participants: RoomMember[]): boolean {
return Array.from(participants).some((participant) => participant.userId !== notificationEvent.sender?.userId);
}
function computeSnapshot(props: Props): CommonOngoingCallTileViewSnapshot {
const mxEvent = props.mxEvent;
const callStore = props.callStore;
const cli = props.cli;
const roomId = mxEvent.getRoomId();
if (!roomId) {
throw new Error("RTCNotification event has no room associated with it!");
}
// Get the call in the room
const call = getCallOrThrow(callStore, roomId);
// 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!");
}
// Get room-member from mx-id
const participants = Array.from(call.participants.keys());
const callHasOtherParticipants = doesCallHaveOtherParticipants(props.mxEvent, participants);
const room = cli.getRoom(roomId);
if (!room) {
throw new Error(`Cannot find room ${roomId}`);
}
const startedMember = mxEvent.sender;
if (!startedMember) {
// This should never happen but just to be safe ...
throw new Error("Event does not have a sender!");
}
const startedByDisplayName = startedMember.name;
// We know we're joined to this call if there's an active call in the room
const isJoined = !!callStore.getActiveCall(roomId);
const callDirection = cli.getUserId() === startedUserId ? CallDirection.Outgoing : CallDirection.Incoming;
// Create the avatar vms
const facePileViewModel = new FacePileViewModel({ size: 20, members: participants, cli });
const memberAvatarViewModel = new MemberAvatarViewModel({ member: startedMember, size: 20, cli });
const isJoinable = !!room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.mayClientSendStateEvent(EventType.GroupCallMemberPrefix, room.client);
const callStartTs = call.session.getOldestMembership()?.createdTs();
let durationViewModel: DurationViewModel | undefined;
if (callStartTs) {
durationViewModel = new DurationViewModel({ callStartTs });
}
return {
startedByDisplayName,
isJoined,
isJoinable,
facePileViewModel,
memberAvatarViewModel,
callDirection,
durationViewModel,
callHasOtherParticipants,
};
}
/**
* A base view model for an ongoing call in the timeline.
*/
export class BaseOngoingCallViewModel<
T extends CommonOngoingCallTileViewSnapshot = CommonOngoingCallTileViewSnapshot,
> extends BaseViewModel<T, Props> {
public constructor(props: Props, extraSnapshot: Partial<T> = {}) {
const snapshot = { ...computeSnapshot(props), ...extraSnapshot };
super(props, snapshot as T);
this.disposables.track(snapshot.facePileViewModel as BaseViewModel<unknown, unknown>);
this.disposables.track(snapshot.memberAvatarViewModel as BaseViewModel<unknown, unknown>);
this.setupListener();
}
private setupListener(): void {
const call = getCallOrThrow(this.props.callStore, this.props.roomId);
this.disposables.trackListener(call, CallEvent.Participants, ((participants: Map<RoomMember, Set<string>>) => {
this.onParticipantsChange(participants);
}) as (...args: unknown[]) => void);
}
/**
* Join the call associated with this tile.
* @param event The button click event
*/
public join(event?: React.MouseEvent<HTMLButtonElement>): void {
const roomId = this.props.roomId;
const room = this.props.cli.getRoom(roomId);
if (!room) {
throw new Error(`Cannot find room ${roomId}`);
}
const callType = getIntentFromEvent(this.props.mxEvent);
const type = callType === SharedComponentsCallType.Voice ? CallType.Voice : CallType.Video;
placeCall(room, type, PlatformCallType.ElementCall, event?.shiftKey || undefined, type === CallType.Voice);
}
/**
* Recomputes the snapshot when the call participants are updated.
*/
protected onParticipantsChange(participants: Map<RoomMember, Set<string>>, extraSnapshot: Partial<T> = {}): void {
const roomId = this.props.roomId;
const isJoined = !!this.props.callStore.getActiveCall(roomId);
const members = Array.from(participants.keys());
const callHasOtherParticipants = doesCallHaveOtherParticipants(this.props.mxEvent, members);
(this.getSnapshot().facePileViewModel as FacePileViewModel).updateMembers(members);
this.snapshot.merge({ isJoined, callHasOtherParticipants, ...extraSnapshot } as Partial<T>);
}
}
@@ -0,0 +1,49 @@
/*
* 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 } from "vitest";
import { CallType } from "@element-hq/web-shared-components";
import { stubClient } from "../../../../../../../../test/test-utils";
import { getMockedMember, getMockedRtcNotificationEvent, MockedCall, MockedCallStore } from "../../call-mocks";
import { DmOngoingCallTileViewModel } from "./DmOngoingCallTileViewModel";
const roomId = "!my-room:m.org";
describe("DmOngoingCallTileViewModel", () => {
describe("should compute the correct snapshot", () => {
describe("callType", () => {
it("voice", () => {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create().withParticipants([mxEvent.sender]);
const callStore = MockedCallStore.create(call);
const vm = new DmOngoingCallTileViewModel({ mxEvent, cli, callStore, roomId });
expect(vm.getSnapshot().callType).toStrictEqual(CallType.Voice);
});
it("video", () => {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("video", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create().withParticipants([mxEvent.sender]);
const callStore = MockedCallStore.create(call);
const vm = new DmOngoingCallTileViewModel({ mxEvent, cli, callStore, roomId });
expect(vm.getSnapshot().callType).toStrictEqual(CallType.Video);
});
});
});
});
@@ -0,0 +1,27 @@
/*
* 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 CommonOngoingCallTileViewAction,
type DmOngoingCallTileViewSnapshot,
} from "@element-hq/web-shared-components";
import { type Props, BaseOngoingCallViewModel } from "./BaseOngoingCallTileViewModel";
import { getIntentFromEvent } from "../../common";
/**
* View model for an ongoing call in a DM room.
*/
export class DmOngoingCallTileViewModel
extends BaseOngoingCallViewModel<DmOngoingCallTileViewSnapshot>
implements CommonOngoingCallTileViewAction
{
public constructor(props: Props) {
const callType = getIntentFromEvent(props.mxEvent);
super(props, { callType });
}
}
@@ -0,0 +1,76 @@
/*
* 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 { EventType, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../../../../../../../test/test-utils";
import {
getMockedMember,
getMockedRtcDeclineEvent,
getMockedRtcNotificationEvent,
MockedCall,
MockedCallStore,
} from "../../call-mocks";
import { RoomOngoingCallTileViewModel } from "./RoomOngoingCallTileViewModel";
import { CallEvent } from "../../../../../../../models/Call";
const roomId = "!my-room:m.org";
describe("RoomOngoingCallTileViewModel", () => {
describe("should compute the correct snapshot", () => {
it("totalParticipants", () => {
const cli = stubClient();
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const bob = getMockedMember(roomId, "@bob:m.org", "Bob");
const james = getMockedMember(roomId, "@james:m.org", "James");
const call = MockedCall.create().withParticipants([mxEvent.sender, bob, james]);
const callStore = MockedCallStore.create(call);
const vm = new RoomOngoingCallTileViewModel({ mxEvent, cli, callStore, roomId });
// Call has 3 participants now
expect(vm.getSnapshot().totalParticipants).toStrictEqual(3);
// Peter also joins the call
const peter = getMockedMember(roomId, "@peter:m.org", "Peter");
call.withParticipants([mxEvent.sender, bob, james, peter]);
call.emit(CallEvent.Participants, call.participants, new Map());
// Now there should be 4 participants
expect(vm.getSnapshot().totalParticipants).toStrictEqual(4);
});
it("isCallIgnored", () => {
const cli = stubClient();
vi.spyOn(cli, "getUserId").mockReturnValue("@alice:m.org");
const getRelationsForEvent = vi.fn();
const mxEvent = getMockedRtcNotificationEvent("audio", 100, 100);
mxEvent.sender = getMockedMember(roomId, "@alice:m.org", "Alice");
const call = MockedCall.create().withParticipants([mxEvent.sender]);
const callStore = MockedCallStore.create(call);
const vm = new RoomOngoingCallTileViewModel({ mxEvent, cli, callStore, roomId, getRelationsForEvent });
// Call hasn't been ignored yet
expect(vm.getSnapshot().isCallIgnored).toStrictEqual(false);
// Ignore the call
const declineEvent = getMockedRtcDeclineEvent(mxEvent, "@alice:m.org");
getRelationsForEvent.mockReturnValue({ getRelations: () => [declineEvent] });
mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", EventType.RTCDecline);
// Call should be ignored
expect(vm.getSnapshot().isCallIgnored).toStrictEqual(true);
});
});
});
@@ -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 CommonOngoingCallTileViewAction,
type RoomOngoingCallTileViewSnapshot,
} from "@element-hq/web-shared-components";
import { MatrixEventEvent, type MatrixEvent, type RoomMember, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type Props, BaseOngoingCallViewModel } from "./BaseOngoingCallTileViewModel";
import { getDeclinedEvents } from "../../common";
import { type GetRelationsForEvent } from "../../../../../../../components/views/rooms/EventTile";
/**
* Check if this call is declined by our user.
*/
function isCallDeclinedByOwnUser(
notificationEvent: MatrixEvent,
getRelationsForEvent: GetRelationsForEvent | undefined,
cli: MatrixClient,
): boolean {
const declinedEvents = getDeclinedEvents(notificationEvent, getRelationsForEvent);
const ownUserId = cli.getUserId();
return declinedEvents?.some((event) => event.getSender() === ownUserId) ?? false;
}
/**
* View model for an ongoing call in a non DM room.
*/
export class RoomOngoingCallTileViewModel
extends BaseOngoingCallViewModel<RoomOngoingCallTileViewSnapshot>
implements CommonOngoingCallTileViewAction
{
public constructor(props: Props) {
// Get the call in the room
const call = props.callStore.getCall(props.roomId);
if (!call) {
throw new Error(`No call in room ${props.roomId}`);
}
const totalParticipants = call.participants.size;
const isCallDeclined = isCallDeclinedByOwnUser(props.mxEvent, props.getRelationsForEvent, props.cli);
super(props, { totalParticipants, isCallIgnored: isCallDeclined });
// When a relation is added to the event, recompute the state.
this.disposables.trackListener(props.mxEvent, MatrixEventEvent.RelationsCreated, () => {
this.onRelationsCreated();
});
}
/**
* Recompute the snapshot when a relation is added to the notification event.
* We do this so that we know if our user has declined (ignored) this call.
*/
private onRelationsCreated(): void {
const isCallIgnored = isCallDeclinedByOwnUser(
this.props.mxEvent,
this.props.getRelationsForEvent,
this.props.cli,
);
this.snapshot.merge({ isCallIgnored });
}
protected onParticipantsChange(participants: Map<RoomMember, Set<string>>): void {
const totalParticipants = participants.size;
super.onParticipantsChange(participants, { totalParticipants });
}
}
@@ -0,0 +1,33 @@
/*
* 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 { describe, it, beforeEach, afterEach, vi, expect } from "vitest";
import { DurationViewModel } from "./DurationViewModel";
describe("DurationViewModel", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.clearAllMocks();
});
it("should compute duration correctly", () => {
const callStartTs = Date.now();
vi.advanceTimersByTime(100 * 1000);
const vm = new DurationViewModel({ callStartTs });
// Time elapsed from callStartTs is 100 seconds, so expect duration to be 100
expect(vm.getSnapshot().duration).toStrictEqual(100);
// Snapshot must update as time goes on
vi.advanceTimersByTime(100 * 1000);
expect(vm.getSnapshot().duration).toStrictEqual(200);
});
});
@@ -0,0 +1,47 @@
/*
* 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 DurationViewSnapshot } from "@element-hq/web-shared-components";
interface Props {
/**
* The timestamp of when this call started.
*/
callStartTs: number;
}
/**
* Get the duration of the call in seconds
* @param timestamp The timestamp at which the call started
*/
function getDurationInSeconds(timestamp: number): number {
const durationInMs = Date.now() - timestamp;
const durationInSeconds = Math.floor(durationInMs * 0.001);
return durationInSeconds;
}
/**
* View model for showing the duration of an ongoing call.
*/
export class DurationViewModel extends BaseViewModel<DurationViewSnapshot, Props> {
public constructor(props: Props) {
super(props, { duration: getDurationInSeconds(props.callStartTs) });
this.setupInterval();
}
/**
* Update the duration every second.
*/
private setupInterval(): void {
const intervalRef = setInterval(() => {
this.snapshot.set({ duration: getDurationInSeconds(this.props.callStartTs) });
}, 1000);
this.disposables.track(() => {
clearInterval(intervalRef);
});
}
}