diff --git a/apps/web/src/components/viewmodels/avatars/FacePileViewModel.test.ts b/apps/web/src/components/viewmodels/avatars/FacePileViewModel.test.ts new file mode 100644 index 00000000000..d884e58f942 --- /dev/null +++ b/apps/web/src/components/viewmodels/avatars/FacePileViewModel.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/components/viewmodels/avatars/FacePileViewModel.ts b/apps/web/src/components/viewmodels/avatars/FacePileViewModel.ts new file mode 100644 index 00000000000..f076c9eafb1 --- /dev/null +++ b/apps/web/src/components/viewmodels/avatars/FacePileViewModel.ts @@ -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. + */ + size: string; + /** + * 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 { + private viewModelMap: Set = 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(); + } +} diff --git a/apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.test.ts b/apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.test.ts new file mode 100644 index 00000000000..ec6b5ccf7f8 --- /dev/null +++ b/apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.test.ts @@ -0,0 +1,56 @@ +/* + * 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 { RoomStateEvent } from "matrix-js-sdk/src/matrix"; + +import { 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"; + // @ts-ignore + room.emit(RoomStateEvent.Members, undefined, undefined, member); + expect(vm.getSnapshot().name).toStrictEqual("Bob"); + }); +}); diff --git a/apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.ts b/apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.ts new file mode 100644 index 00000000000..e3a4a2e0478 --- /dev/null +++ b/apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.ts @@ -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. + */ + +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. + */ + size: string; + /** + * 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( + parseInt(size, 10), + parseInt(size, 10), + "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 { + 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); + } + }; +} diff --git a/apps/web/src/contexts/SDKContextClass.ts b/apps/web/src/contexts/SDKContextClass.ts index fc750ec8a02..6403dc101a8 100644 --- a/apps/web/src/contexts/SDKContextClass.ts +++ b/apps/web/src/contexts/SDKContextClass.ts @@ -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; diff --git a/apps/web/src/events/EventTileFactory.tsx b/apps/web/src/events/EventTileFactory.tsx index c9d9a3c4b06..53c073619bf 100644 --- a/apps/web/src/events/EventTileFactory.tsx +++ b/apps/web/src/events/EventTileFactory.tsx @@ -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) => ; 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 ; diff --git a/apps/web/src/stores/CallStore.ts b/apps/web/src/stores/CallStore.ts index 119a0f6d6e8..6c2d4a3bf00 100644 --- a/apps/web/src/stores/CallStore.ts +++ b/apps/web/src/stores/CallStore.ts @@ -210,6 +210,14 @@ export class CallStore extends AsyncStoreWithClient { 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 { + return this.calls; + } + private onWidgets = (roomId: string | null): void => { if (!this.matrixClient) return; if (roomId === null) { diff --git a/apps/web/src/stores/LatestRtcNotificationEventStore.test.ts b/apps/web/src/stores/LatestRtcNotificationEventStore.test.ts new file mode 100644 index 00000000000..6d2e1a839dc --- /dev/null +++ b/apps/web/src/stores/LatestRtcNotificationEventStore.test.ts @@ -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; + }; + + 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; + }; + 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"); + }); + }); +}); diff --git a/apps/web/src/stores/LatestRtcNotificationEventStore.ts b/apps/web/src/stores/LatestRtcNotificationEventStore.ts new file mode 100644 index 00000000000..69e5ac9ace5 --- /dev/null +++ b/apps/web/src/stores/LatestRtcNotificationEventStore.ts @@ -0,0 +1,195 @@ +/* + * 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, + MatrixEventEvent, + 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 { + private eventIdMap = new Map(); + + 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 { + 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 { + this.callStore.on(CallStoreEvent.Call, this.onCallStoreEvent); + this.populateMap(); + } + + protected async onNotReady(): Promise { + this.callStore.off(CallStoreEvent.Call, this.onCallStoreEvent); + this.eventIdMap.clear(); + } + + protected async onAction(): Promise { + // nothing to do + } + + private onCallStoreEvent = async (_: unknown, roomId: string): Promise => { + 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 { + 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 { + const events = room.getLiveTimeline().getEvents(); + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + + if (event.getType() === EventType.RoomMessageEncrypted) { + await new Promise((r) => { + event.once(MatrixEventEvent.Decrypted, () => { + r(); + }); + }); + } + + 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 { + const { promise, resolve, reject } = Promise.withResolvers(); + + // 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 => { + // Wait for decryption if necessary + if (event.getType() === EventType.RoomMessageEncrypted) { + await new Promise((r) => { + event.once(MatrixEventEvent.Decrypted, () => { + r(); + }); + }); + } + + 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); + + // We might already have the notification event in the timeline, so check that as well. + const event = await getLastRtcNotificationEvent(room); + const relatedEventId = event?.getRelation()?.event_id; + const eventId = event?.getId(); + if (relatedEventId === callMemberEventId && eventId) return eventId; + else return await promise; +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.test.tsx b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.test.tsx index 3f2004ae8ef..c6cdddcbcac 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.test.tsx +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.test.tsx @@ -9,10 +9,12 @@ 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 { type LatestRtcNotificationEventStore } from "../../../../../stores/LatestRtcNotificationEventStore"; function getEvents(): MatrixEvent[] { const message1 = mkMessage({ @@ -80,21 +82,49 @@ 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"); }); }); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.ts index bbc3cdeab3f..9b5ea1b3c4f 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/RootCallTileViewModel.ts @@ -6,11 +6,18 @@ */ 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"; interface Props { /** @@ -27,6 +34,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 +56,46 @@ 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 hasOngoingCall = !!props.callStore.getCall(roomId); + // 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 +119,53 @@ export class RootCallTileViewModel extends BaseViewModel 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).dispose(); + return; + } + + this.trackViewModel(snapshot.tileViewModel); + this.snapshot.set(snapshot); + } + + private trackViewModel(vm: ViewModel): void { + // ViewModel type has no dispose method, so this needs a type assertion. + this.disposables.track(vm as BaseViewModel); } } diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/call-mocks.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/call-mocks.ts index 9a4a61b1db5..779fb2ee2e3 100644 --- a/apps/web/src/viewmodels/room/timeline/event-tile/call/call-mocks.ts +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/call-mocks.ts @@ -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; diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/BaseOngoingCallTileViewModel.test.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/BaseOngoingCallTileViewModel.test.ts new file mode 100644 index 00000000000..82366dd495e --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/BaseOngoingCallTileViewModel.test.ts @@ -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); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/BaseOngoingCallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/BaseOngoingCallTileViewModel.ts new file mode 100644 index 00000000000..609e2ac435b --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/BaseOngoingCallTileViewModel.ts @@ -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 getCallInRoom(store: CallStore, roomId: string): ElementCall { + const call = store.getCall(roomId) as ElementCall | null; + if (!call) { + throw new Error(`No call in room ${roomId}`); + } + return call; +} + +/** + * 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 = getCallInRoom(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 { + public constructor(props: Props, extraSnapshot: Partial = {}) { + const snapshot = { ...computeSnapshot(props), ...extraSnapshot }; + super(props, snapshot as T); + this.disposables.track(snapshot.facePileViewModel as BaseViewModel); + this.disposables.track(snapshot.memberAvatarViewModel as BaseViewModel); + this.setupListener(); + } + + private setupListener(): void { + const call = getCallInRoom(this.props.callStore, this.props.roomId); + this.disposables.trackListener(call, CallEvent.Participants, ((participants: Map>) => { + 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): 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>, extraSnapshot: Partial = {}): 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); + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/DmOngoingCallTileViewModel.test.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/DmOngoingCallTileViewModel.test.ts new file mode 100644 index 00000000000..cd57ece0a91 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/DmOngoingCallTileViewModel.test.ts @@ -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); + }); + }); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/DmOngoingCallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/DmOngoingCallTileViewModel.ts new file mode 100644 index 00000000000..0cb16e21cf8 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/DmOngoingCallTileViewModel.ts @@ -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 + implements CommonOngoingCallTileViewAction +{ + public constructor(props: Props) { + const callType = getIntentFromEvent(props.mxEvent); + super(props, { callType }); + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/RoomOngoingCallTileViewModel.test.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/RoomOngoingCallTileViewModel.test.ts new file mode 100644 index 00000000000..e2c95742b52 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/RoomOngoingCallTileViewModel.test.ts @@ -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); + }); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/RoomOngoingCallTileViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/RoomOngoingCallTileViewModel.ts new file mode 100644 index 00000000000..a5e170647b6 --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/RoomOngoingCallTileViewModel.ts @@ -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 + 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 isCallIgnored = isCallDeclinedByOwnUser(props.mxEvent, props.getRelationsForEvent, props.cli); + super(props, { totalParticipants, isCallIgnored }); + + // 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>): void { + const totalParticipants = participants.size; + super.onParticipantsChange(participants, { totalParticipants }); + } +} diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/components/DurationViewModel.test.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/components/DurationViewModel.test.ts new file mode 100644 index 00000000000..2556b0fb49a --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/components/DurationViewModel.test.ts @@ -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 }); + + // Initial duration is 100 seconds + expect(vm.getSnapshot().duration).toStrictEqual(100); + + // Snapshot must update as time goes on + vi.advanceTimersByTime(100 * 1000); + expect(vm.getSnapshot().duration).toStrictEqual(200); + }); +}); diff --git a/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/components/DurationViewModel.ts b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/components/DurationViewModel.ts new file mode 100644 index 00000000000..2385ee6bfdc --- /dev/null +++ b/apps/web/src/viewmodels/room/timeline/event-tile/call/tiles/ongoing/components/DurationViewModel.ts @@ -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 { + 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); + }); + } +}