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:
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+55
-6
@@ -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;
|
||||
|
||||
+159
@@ -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);
|
||||
});
|
||||
});
|
||||
+184
@@ -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>);
|
||||
}
|
||||
}
|
||||
+49
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
+27
@@ -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 });
|
||||
}
|
||||
}
|
||||
+76
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+71
@@ -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 });
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
});
|
||||
});
|
||||
+47
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user