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); + }); + } +} diff --git a/packages/shared-components/__vis__/linux/__baselines__/audio/Clock/Clock.stories.tsx/single-digit-minutes-auto.png b/packages/shared-components/__vis__/linux/__baselines__/audio/Clock/Clock.stories.tsx/single-digit-minutes-auto.png new file mode 100644 index 00000000000..b10ca2b05eb Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/audio/Clock/Clock.stories.tsx/single-digit-minutes-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/FacePile/FacePileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/FacePile/FacePileView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..2aa7004c518 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/FacePile/FacePileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/core/MemberAvatar/MemberAvatarView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/core/MemberAvatar/MemberAvatarView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..b53127dc269 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/core/MemberAvatar/MemberAvatarView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-blurhash-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-blurhash-auto.png index 16ea6246ae4..da89c68f688 100644 Binary files a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-blurhash-auto.png and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageReplyBodyView/ImageReplyBodyView.stories.tsx/loading-with-blurhash-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..0084a30c998 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-video-call-auto.png new file mode 100644 index 00000000000..2074ef2e2bc Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-video-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-voice-call-auto.png new file mode 100644 index 00000000000..f64c33841a7 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/incoming-voice-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-video-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-video-call-auto.png new file mode 100644 index 00000000000..c25642b2594 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-video-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-voice-call-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-voice-call-auto.png new file mode 100644 index 00000000000..2a67aaf2018 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/outgoing-voice-call-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/video-call-in-progress-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/video-call-in-progress-auto.png new file mode 100644 index 00000000000..0b68335e8bc Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/video-call-in-progress-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/voice-call-in-progress-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/voice-call-in-progress-auto.png new file mode 100644 index 00000000000..b1e3558aa40 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx/voice-call-in-progress-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/default-auto.png new file mode 100644 index 00000000000..aacf440ce87 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-ignored-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-ignored-auto.png new file mode 100644 index 00000000000..b83e4dbd4b5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-ignored-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-joined-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-joined-auto.png new file mode 100644 index 00000000000..638ec1f9db3 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-joined-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-without-other-participants-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-without-other-participants-auto.png new file mode 100644 index 00000000000..943216cf831 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx/room-call-without-other-participants-auto.png differ diff --git a/packages/shared-components/src/audio/Clock/Clock.stories.tsx b/packages/shared-components/src/audio/Clock/Clock.stories.tsx index df2fab9ffc1..90818e4b65b 100644 --- a/packages/shared-components/src/audio/Clock/Clock.stories.tsx +++ b/packages/shared-components/src/audio/Clock/Clock.stories.tsx @@ -27,3 +27,10 @@ export const LotOfSeconds: Story = { seconds: 99999999999999, }, }; + +export const SingleDigitMinutes: Story = { + args: { + seconds: 100, + minutesMaxLength: 1, + }, +}; diff --git a/packages/shared-components/src/audio/Clock/Clock.test.tsx b/packages/shared-components/src/audio/Clock/Clock.test.tsx index 9350b7cae3b..895f29d3eb7 100644 --- a/packages/shared-components/src/audio/Clock/Clock.test.tsx +++ b/packages/shared-components/src/audio/Clock/Clock.test.tsx @@ -12,7 +12,7 @@ import { describe, it, expect } from "vitest"; import * as stories from "./Clock.stories.tsx"; -const { Default, LotOfSeconds } = composeStories(stories); +const { Default, LotOfSeconds, SingleDigitMinutes } = composeStories(stories); describe("Clock", () => { it("renders the clock", () => { @@ -24,4 +24,9 @@ describe("Clock", () => { const { container } = render(); expect(container).toMatchSnapshot(); }); + + it("renders the clock with single digit minute", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); }); diff --git a/packages/shared-components/src/audio/Clock/Clock.tsx b/packages/shared-components/src/audio/Clock/Clock.tsx index 2bbe6e4a014..54b86b85b81 100644 --- a/packages/shared-components/src/audio/Clock/Clock.tsx +++ b/packages/shared-components/src/audio/Clock/Clock.tsx @@ -16,6 +16,22 @@ export interface Props extends Pick, "aria-live" | "r * The number of seconds to display. */ seconds: number; + + /** + * The number of positions to pad the minutes part. + * + * @example + * If minutesMaxLength = 1, the clock will show 5:31 instead of 05:31. + */ + minutesMaxLength?: number; + + /** + * The number of positions to pad the hour part. + * + * @example + * If hoursMaxLength = 1, the clock will show 1:05:31 instead of 01:05:31. + */ + hoursMaxLength?: number; } /** @@ -28,7 +44,7 @@ export interface Props extends Pick, "aria-live" | "r * * ``` */ -export function Clock({ seconds, className, ...rest }: Props): JSX.Element { +export function Clock({ seconds, className, minutesMaxLength, hoursMaxLength, ...rest }: Props): JSX.Element { // Memoize current second to avoid recalculating the duration when seconds changes slightly (e.g. 1.2 -> 1.3) const currentSecond = useMemo(() => Math.floor(seconds), [seconds]); const duration = useMemo(() => calculateDuration(currentSecond), [currentSecond]); @@ -40,7 +56,10 @@ export function Clock({ seconds, className, ...rest }: Props): JSX.Element { className={classNames("mx_Clock", className)} {...rest} > - {formatSeconds(seconds)} + {formatSeconds(seconds, { + minutesMaxLength, + hoursMaxLength, + })} ); } diff --git a/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap b/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap index e400b9dc5b9..ee41a0c8432 100644 --- a/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap +++ b/packages/shared-components/src/audio/Clock/__snapshots__/Clock.test.tsx.snap @@ -21,3 +21,14 @@ exports[`Clock > renders the clock with a lot of seconds 1`] = ` `; + +exports[`Clock > renders the clock with single digit minute 1`] = ` +
+ +
+`; diff --git a/packages/shared-components/src/core/FacePile/FacePileView.stories.tsx b/packages/shared-components/src/core/FacePile/FacePileView.stories.tsx new file mode 100644 index 00000000000..962bc131b84 --- /dev/null +++ b/packages/shared-components/src/core/FacePile/FacePileView.stories.tsx @@ -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. + */ + +import React from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type FacePileViewSnapshot, FacePileView } from "./FacePileView"; +import { withViewDocs } from "../../../.storybook/withViewDocs"; +import profileLink1 from "../../../static/profile-pics/profile1.png"; +import profileLink2 from "../../../static/profile-pics/profile2.png"; +import profileLink3 from "../../../static/profile-pics/profile3.png"; +import { MockViewModel, useMockedViewModel } from "../viewmodel"; +import { type MemberAvatarViewSnapshot } from "../MemberAvatar/MemberAvatarView"; + +const FacePileViewWrapperImpl = (snapshot: FacePileViewSnapshot): React.ReactNode => { + const vm = useMockedViewModel(snapshot, {}); + return ; +}; + +const FacePileViewWrapper = withViewDocs(FacePileViewWrapperImpl, FacePileView); + +type MockProp = { id: string; name: string; url?: string }; + +export class MockMemberAvatarViewModel extends MockViewModel { + public constructor({ id, name, url }: MockProp) { + super({ + id, + name, + size: "20px", + title: id, + url, + }); + } +} + +const meta = { + title: "Core/FacePileView", + component: FacePileViewWrapper, + tags: ["autodocs"], + args: { + memberAvatarViewModels: [ + new MockMemberAvatarViewModel({ id: "@bob:m.org", name: "Bob", url: profileLink1 }), + new MockMemberAvatarViewModel({ id: "@riley:m.org", name: "Riley", url: profileLink2 }), + new MockMemberAvatarViewModel({ id: "@andi:m.org", name: "Andi", url: profileLink3 }), + ], + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/core/FacePile/FacePileView.tsx b/packages/shared-components/src/core/FacePile/FacePileView.tsx new file mode 100644 index 00000000000..878f1b320cc --- /dev/null +++ b/packages/shared-components/src/core/FacePile/FacePileView.tsx @@ -0,0 +1,45 @@ +/* + * 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 React from "react"; +import { AvatarStack } from "@vector-im/compound-web"; + +import { type MemberAvatarViewModel, MemberAvatarView } from "../MemberAvatar/MemberAvatarView"; +import { useViewModel, type ViewModel } from "../viewmodel"; + +export interface FacePileViewSnapshot { + /** + * The sub vms for the member avatars. + */ + memberAvatarViewModels: MemberAvatarViewModel[]; +} + +export type FacePileViewModel = ViewModel; + +export interface FacePileViewProps { + vm: FacePileViewModel; + + /** + * Additional class names for this component. + */ + classNames?: string; +} + +/** + * View that renders a face pile view. + */ +export function FacePileView(props: FacePileViewProps): React.ReactNode { + const { memberAvatarViewModels } = useViewModel(props.vm); + if (memberAvatarViewModels.length === 0) return null; + return ( + + {memberAvatarViewModels.map((vm) => ( + + ))} + + ); +} diff --git a/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.stories.tsx b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.stories.tsx new file mode 100644 index 00000000000..952edbd005d --- /dev/null +++ b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.stories.tsx @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type MemberAvatarViewSnapshot, MemberAvatarView } from "./MemberAvatarView"; +import { withViewDocs } from "../../../.storybook/withViewDocs"; +import profile from "../../../static/profile-pics/profile3.png"; +import { useMockedViewModel } from "../viewmodel"; + +const MemberAvatarViewWrapperImpl = (snapshot: MemberAvatarViewSnapshot): React.ReactNode => { + const vm = useMockedViewModel(snapshot, {}); + return ; +}; + +const MemberAvatarViewWrapper = withViewDocs(MemberAvatarViewWrapperImpl, MemberAvatarView); + +const meta = { + title: "Core/MemberAvatarView", + component: MemberAvatarViewWrapper, + tags: ["autodocs"], + argTypes: { + url: { + control: "text", + }, + }, + args: { + id: "@bob:m.org", + name: "Bob", + size: "20px", + title: "@bob:m.org", + url: profile, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.tsx b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.tsx new file mode 100644 index 00000000000..b3b541c14c4 --- /dev/null +++ b/packages/shared-components/src/core/MemberAvatar/MemberAvatarView.tsx @@ -0,0 +1,54 @@ +/* + * 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 React from "react"; +import { Avatar } from "@vector-im/compound-web"; + +import { useViewModel, type ViewModel } from "../viewmodel"; + +export interface MemberAvatarViewSnapshot { + /** + * The display name of this member. + */ + name: string; + /** + * The mxid of this member. + */ + id: string; + /** + * The avatar url. + */ + url?: string; + /** + * Size of the avatar. + */ + size: string; + /** + * Title passed to the avatar container (button or span). + */ + title?: string; +} + +export type MemberAvatarViewModel = ViewModel; + +interface MemberAvatarViewProps { + vm: MemberAvatarViewModel; + + /** + * Additional class names for this component. + */ + classNames?: string; +} + +/** + * View for rendering the avatar for a given member. + */ +export function MemberAvatarView(props: MemberAvatarViewProps): React.ReactNode { + const { name, id, url, size } = useViewModel(props.vm); + + return ; +} diff --git a/packages/shared-components/src/core/utils/DateUtils.ts b/packages/shared-components/src/core/utils/DateUtils.ts index 6df2836d20e..2cbcc9c263e 100644 --- a/packages/shared-components/src/core/utils/DateUtils.ts +++ b/packages/shared-components/src/core/utils/DateUtils.ts @@ -9,16 +9,21 @@ * Formats a number of seconds into a human-readable string. * @param inSeconds */ -export function formatSeconds(inSeconds: number): string { +export function formatSeconds( + inSeconds: number, + opts?: { hoursMaxLength?: number; minutesMaxLength?: number }, +): string { const isNegative = inSeconds < 0; inSeconds = Math.abs(inSeconds); const hours = Math.floor(inSeconds / (60 * 60)) .toFixed(0) - .padStart(2, "0"); + .padStart(opts?.hoursMaxLength ?? 2, "0"); + const minutes = Math.floor((inSeconds % (60 * 60)) / 60) .toFixed(0) - .padStart(2, "0"); + .padStart(opts?.minutesMaxLength ?? 2, "0"); + const seconds = Math.floor((inSeconds % (60 * 60)) % 60) .toFixed(0) .padStart(2, "0"); diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 51a7aa1296b..054f43c9354 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -231,6 +231,20 @@ "call_declined": "Call declined", "call_declined_by_us": "You declined a call" }, + "ongoing": { + "common": { + "call_started_by": "%(startedByDisplayName)s started a call", + "join_button": "Join" + }, + "dm": { + "call_started": "Call started", + "title": "Call in progress" + }, + "room": { + "join_count": "%(joinedCount)s joined", + "title": "Group call in progress" + } + }, "tombstone": { "room": { "title": "Group call ended" diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 41ef3a54fb0..8de6b3b73f4 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -11,6 +11,8 @@ export * from "./audio/Clock"; export * from "./audio/PlayPauseButton"; export * from "./audio/SeekBar"; export * from "./core/AvatarWithDetails"; +export * from "./core/MemberAvatar/MemberAvatarView.tsx"; +export * from "./core/FacePile/FacePileView.tsx"; export * from "./core/roving"; export * from "./room/composer/Banner"; export * from "./room/composer/UploadButton"; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx index dadf9ac9c91..0d880f9e207 100644 --- a/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx +++ b/packages/shared-components/src/room/timeline/event-tile/call/RootCallTileView.tsx @@ -8,6 +8,8 @@ import React from "react"; import { useViewModel, type ViewModel } from "../../../../core/viewmodel"; +import { RoomOngoingCallTileView, type RoomCallStartedTileViewModel } from "./ongoing/room/RoomOngoingCallTileView"; +import { DmOngoingCallTileView, type DmOngoingCallTileViewModel } from "./ongoing/dm/DmOngoingCallTileView"; import { RoomTombstoneCallTileView, type RoomTombstoneCallTileViewModel, @@ -18,6 +20,8 @@ import { DmTombstoneCallTileView, type DmTombstoneCallTileViewModel } from "./to * Map from tile type to view model. */ interface TileTypeToViewModelMap { + "ongoing-call-room": RoomCallStartedTileViewModel; + "ongoing-call-dm": DmOngoingCallTileViewModel; "tombstone-call-room": RoomTombstoneCallTileViewModel; "tombstone-call-dm": DmTombstoneCallTileViewModel; } @@ -39,6 +43,10 @@ interface Props { export function RootCallTileView({ vm }: Props): React.ReactNode { const { tileType, tileViewModel } = useViewModel(vm); switch (tileType) { + case "ongoing-call-room": + return ; + case "ongoing-call-dm": + return ; case "tombstone-call-room": return ; case "tombstone-call-dm": diff --git a/packages/shared-components/src/room/timeline/event-tile/call/index.ts b/packages/shared-components/src/room/timeline/event-tile/call/index.ts index 6a710d4e674..5ed5bb3989f 100644 --- a/packages/shared-components/src/room/timeline/event-tile/call/index.ts +++ b/packages/shared-components/src/room/timeline/event-tile/call/index.ts @@ -7,5 +7,9 @@ export * from "./tombstone/room/RoomTombstoneCallTileView"; export * from "./tombstone/dm/DmTombstoneCallTileView"; +export type * from "./ongoing/common"; +export * from "./ongoing/room/RoomOngoingCallTileView"; +export * from "./ongoing/components/Duration/DurationView"; +export * from "./ongoing/dm/DmOngoingCallTileView"; export * from "./RootCallTileView"; export * from "./common"; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/call-mocks.ts b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/call-mocks.ts new file mode 100644 index 00000000000..1b32b6d8e01 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/call-mocks.ts @@ -0,0 +1,31 @@ +/* + * 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 FacePileViewSnapshot } from "../../../../../core/FacePile/FacePileView"; +import { type MemberAvatarViewSnapshot } from "../../../../../core/MemberAvatar/MemberAvatarView"; +import { MockViewModel } from "../../../../../core/viewmodel"; + +type MockProp = { id: string; name: string; url?: string }; + +export class MockMemberAvatarViewModel extends MockViewModel { + public constructor({ id, name, url }: MockProp) { + super({ + id, + name, + size: "20px", + title: id, + url, + }); + } +} + +export class MockFacePileViewModel extends MockViewModel { + public constructor(items: MockProp[]) { + const memberAvatarViewModels = items.slice(0, 3).map((item) => new MockMemberAvatarViewModel(item)); + super({ memberAvatarViewModels }); + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.module.css new file mode 100644 index 00000000000..bf6d897ccd8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.module.css @@ -0,0 +1,30 @@ +/* + * 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. + */ + +.title { + font: var(--cpd-font-body-md-semibold); + color: var(--cpd-color-text-primary); + min-height: 23px; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + width: 100%; +} + +.content { + flex: 1; + overflow: hidden; + min-width: 47px; +} + +.avatar { + min-width: 20px; +} + +.facepile { + min-width: fit-content; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.ts b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.ts new file mode 100644 index 00000000000..90581404d9e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/common.ts @@ -0,0 +1,64 @@ +/* + * 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 FacePileViewModel } from "../../../../../core/FacePile/FacePileView"; +import { type MemberAvatarViewModel } from "../../../../../core/MemberAvatar/MemberAvatarView"; +import { type CallDirection } from "../common"; +import { type DurationViewModel } from "./components/Duration/DurationView"; + +/** + * This snapshot type contains state used by both RoomOngoingCallTileView and + * DmOngoingCallTileView. + */ +export interface CommonOngoingCallTileViewSnapshot { + /** + * The display name of whoever started this call. + */ + startedByDisplayName: string; + + /** + * Vm for rendering the duration of this call. + */ + durationViewModel?: DurationViewModel; + + /** + * Avatar vm for the user who started this call. + */ + memberAvatarViewModel: MemberAvatarViewModel; + + /** + * Face pile view-model for the participants on this call. + */ + facePileViewModel: FacePileViewModel; + + /** + * Whether this is an incoming or outgoing call. + */ + callDirection: CallDirection; + + /** + * Whether our user has joined this call. + */ + isJoined: boolean; + + /** + * Whether our user can join this call or not. + */ + isJoinable: boolean; + + /** + * Whether this call has participants other than who started the call. + */ + callHasOtherParticipants: boolean; +} + +export interface CommonOngoingCallTileViewAction { + /** + * Join this call + */ + join: (event: React.MouseEvent) => void; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.module.css new file mode 100644 index 00000000000..316ca334074 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.module.css @@ -0,0 +1,17 @@ +/* + * 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. + */ + +.container { + min-width: 36px; + height: 36px; + border-radius: var(--cpd-space-2x); + background-color: var(--cpd-color-bg-subtle-secondary); +} + +.icon { + color: var(--cpd-color-icon-primary); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.tsx new file mode 100644 index 00000000000..fa124e012e7 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/CallIcon/CallIcon.tsx @@ -0,0 +1,31 @@ +/* + * 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 React from "react"; + +import type { CallType } from "../../../common"; +import { Flex } from "../../../../../../../core/utils/Flex"; +import styles from "./CallIcon.module.css"; +import { Icon } from "../Icon/Icon"; + +interface Props { + /** + * The type of call. + */ + callType: CallType; +} + +/** + * Common call icon component that is used by ongoing call tiles. + */ +export function CallIcon(props: Props): React.ReactNode { + return ( + + + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.module.css new file mode 100644 index 00000000000..37a84a3f8ce --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.module.css @@ -0,0 +1,12 @@ +/* + * 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. + */ + +.container { + font: var(--cpd-font-body-xs-regular); + color: var(--cpd-color-text-secondary); + min-width: 30px; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.tsx new file mode 100644 index 00000000000..b7183346e4f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Duration/DurationView.tsx @@ -0,0 +1,45 @@ +/* + * 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 React from "react"; +import classNames from "classnames"; + +import styles from "./DurationView.module.css"; +import { useViewModel, type ViewModel } from "../../../../../../../core/viewmodel"; +import { Clock } from "../../../../../../../audio/Clock"; + +export interface DurationViewSnapshot { + /** + * The number of seconds that this call has been ongoing for. + */ + duration: number; +} + +export type DurationViewModel = ViewModel; + +interface Props { + vm: DurationViewModel; + + /** + * Additional class names for this component. + */ + classNames?: string; +} + +/** + * View to show the duration of the call. + */ +export function DurationView(props: Props): React.ReactNode { + const { duration } = useViewModel(props.vm); + const classes = classNames(styles.container, props.classNames); + + return ( +
+ () +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Icon/Icon.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Icon/Icon.tsx new file mode 100644 index 00000000000..a5b59213cb8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/Icon/Icon.tsx @@ -0,0 +1,42 @@ +/* + * 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 React, { type ComponentType, type SVGAttributes } from "react"; +import { VideoCallSolidIcon, VoiceCallSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; + +import { CallType } from "../../../common"; + +interface Props extends SVGAttributes { + /** + * The type of this call i.e voice or video. + */ + callType: CallType; + /** + * The extra class names to add to the icon. + */ + classNames?: string; + /** + * Height of the icon + */ + height: number; + /** + * Width of the icon + */ + width: number; +} + +/** + * Component that renders the correct svg icon based on call type. + */ +export const Icon: ComponentType = ({ callType, classNames, height, width, ...rest }: Props) => { + switch (callType) { + case CallType.Video: + return ; + case CallType.Voice: + return ; + } +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/JoinButton/JoinButton.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/JoinButton/JoinButton.tsx new file mode 100644 index 00000000000..05aa637cf6e --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/JoinButton/JoinButton.tsx @@ -0,0 +1,36 @@ +/* + * 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 { Button } from "@vector-im/compound-web"; +import React, { type ComponentProps, type SVGAttributes } from "react"; + +import { Icon } from "../Icon/Icon"; +import type { CallType } from "../../../common"; +import { useI18n } from "../../../../../../../core/i18n/i18nContext"; + +interface Props extends ComponentProps { + join: (event: React.MouseEvent) => void; + callType: CallType; +} + +function withIcon(callType: CallType) { + return function ButtonIcon(props: SVGAttributes) { + return ; + }; +} + +/** + * Join button used by all ongoing call tiles. + */ +export function JoinButton({ join, callType, ...rest }: Props): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.module.css new file mode 100644 index 00000000000..8afd33f2438 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.module.css @@ -0,0 +1,16 @@ +/* + * 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. + */ + +.container { + min-height: 70px; + padding: var(--cpd-space-3x); + border: solid 1px var(--cpd-color-border-interactive-primary); + border-radius: var(--cpd-space-2x); + box-sizing: border-box; + width: 100%; + margin: 10px 0; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.tsx new file mode 100644 index 00000000000..3b2a9681009 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/components/TileContainer/TileContainer.tsx @@ -0,0 +1,22 @@ +/* + * 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 React from "react"; + +import styles from "./TileContainer.module.css"; +import { Flex } from "../../../../../../../core/utils/Flex"; + +/** + * Common container inside which all ongoing call tiles render. + */ +export function TileContainer({ children }: React.PropsWithChildren): React.ReactNode { + return ( + + {children} + + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx new file mode 100644 index 00000000000..0aee39a93b7 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.stories.tsx @@ -0,0 +1,118 @@ +/* + * 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 React from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type DmOngoingCallTileViewSnapshot, DmOngoingCallTileView } from "./DmOngoingCallTileView"; +import { MockViewModel, useMockedViewModel } from "../../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../../.storybook/withViewDocs"; +import { type CommonOngoingCallTileViewAction } from "../common"; +import { CallDirection, CallType } from "../../common"; +import profileLink1 from "../../../../../../../static/profile-pics/profile1.png"; +import profileLink2 from "../../../../../../../static/profile-pics/profile2.png"; +import { MockFacePileViewModel, MockMemberAvatarViewModel } from "../call-mocks"; + +const DmOngoingCallTileViewWrapperImpl = ({ + join, + ...rest +}: DmOngoingCallTileViewSnapshot & CommonOngoingCallTileViewAction): React.ReactNode => { + const vm = useMockedViewModel(rest, { join }); + return ; +}; + +const DmOngoingCallTileViewWrapper = withViewDocs(DmOngoingCallTileViewWrapperImpl, DmOngoingCallTileView); + +const meta = { + title: "Timeline/Timeline Event/Call/Ongoing/DmOngoingCallTileView", + component: DmOngoingCallTileViewWrapper, + tags: ["autodocs"], + argTypes: { + callDirection: { + options: [CallDirection.Incoming, CallDirection.Outgoing], + control: { type: "radio" }, + }, + callType: { + options: [CallType.Voice, CallType.Video], + control: { type: "radio" }, + }, + }, + args: { + callDirection: CallDirection.Incoming, + startedByDisplayName: "Bob", + durationViewModel: new MockViewModel({ duration: 100 }), + isJoinable: true, + isJoined: false, + join: () => {}, + memberAvatarViewModel: new MockMemberAvatarViewModel({ id: "@bob:m.org", name: "Bob", url: profileLink1 }), + facePileViewModel: new MockFacePileViewModel([ + { id: "@bob:m.org", name: "Bob", url: profileLink1 }, + { id: "@riley:m.org", name: "Riley", url: profileLink2 }, + ]), + callType: CallType.Voice, + callHasOtherParticipants: false, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3901&t=OvT1LOc5wH4kXt0a-4", + }, + visOptions: { + mask: [".duration"], + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const IncomingVoiceCall: Story = { + args: { + callDirection: CallDirection.Incoming, + callType: CallType.Voice, + }, +}; + +export const IncomingVideoCall: Story = { + args: { + callDirection: CallDirection.Incoming, + callType: CallType.Video, + }, +}; + +export const OutgoingVoiceCall: Story = { + args: { + callDirection: CallDirection.Outgoing, + callType: CallType.Voice, + }, +}; + +export const OutgoingVideoCall: Story = { + args: { + callDirection: CallDirection.Outgoing, + callType: CallType.Video, + }, +}; + +export const VoiceCallInProgress: Story = { + args: { + callType: CallType.Voice, + isJoined: true, + callHasOtherParticipants: true, + }, +}; + +export const VideoCallInProgress: Story = { + args: { + callType: CallType.Video, + isJoined: true, + callHasOtherParticipants: true, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.test.tsx new file mode 100644 index 00000000000..e98e298fef7 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.test.tsx @@ -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. + */ + +import { composeStories } from "@storybook/react-vite"; +import { describe, expect, it } from "vitest"; +import React from "react"; +import { render } from "@test-utils"; + +import * as Stories from "./DmOngoingCallTileView.stories"; + +const { + IncomingVideoCall, + IncomingVoiceCall, + OutgoingVideoCall, + OutgoingVoiceCall, + VideoCallInProgress, + VoiceCallInProgress, +} = composeStories(Stories); + +describe("DmOngoingCallTileView", () => { + describe("renders the tile", () => { + it("IncomingVideoCall", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("IncomingVoiceCall", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("OutgoingVideoCall", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("OutgoingVoiceCall", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("VoiceCallInProgress", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("VideoCallInProgress", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.tsx new file mode 100644 index 00000000000..ffb8ddaa015 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/DmOngoingCallTileView.tsx @@ -0,0 +1,97 @@ +/* + * 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 React from "react"; + +import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel"; +import { TileContainer } from "../components/TileContainer/TileContainer"; +import { CallIcon } from "../components/CallIcon/CallIcon"; +import { CallDirection, type CallType } from "../../common"; +import { Flex } from "../../../../../../core/utils/Flex"; +import commonStyles from "../common.module.css"; +import { JoinButton } from "../components/JoinButton/JoinButton"; +import { DurationView } from "../components/Duration/DurationView"; +import { MemberAvatarView } from "../../../../../../core/MemberAvatar/MemberAvatarView"; +import { type CommonOngoingCallTileViewSnapshot, type CommonOngoingCallTileViewAction } from "../common"; +import { FacePileView } from "../../../../../../core/FacePile/FacePileView"; +import { useI18n } from "../../../../../../core/i18n/i18nContext"; + +export interface DmOngoingCallTileViewSnapshot extends CommonOngoingCallTileViewSnapshot { + callType: CallType; +} + +export type DmOngoingCallTileViewModel = ViewModel & CommonOngoingCallTileViewAction; + +interface Props { + vm: DmOngoingCallTileViewModel; +} + +/** + * View that renders the tile content for an ongoing call in a DM. + */ +export function DmOngoingCallTileView(props: Props): React.ReactNode { + const snapshot = useViewModel(props.vm); + const { callType, callDirection, isJoinable, isJoined, durationViewModel } = snapshot; + let content: React.ReactNode; + if (snapshot.callHasOtherParticipants) { + content = ; + } else if (callDirection === CallDirection.Incoming) { + content = ; + } else { + content = ; + } + return ( + + + + + {content} + + + {durationViewModel && } + {!isJoined && ( + props.vm.join(ev)} /> + )} + + + + ); +} + +function IncomingCallContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + <> + +
+ {_t("timeline|call_tile|ongoing|common|call_started_by", { + startedByDisplayName: snapshot.startedByDisplayName, + })} +
+ + ); +} + +function OutgoingCallContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + <> + +
{_t("timeline|call_tile|ongoing|dm|call_started")}
+ + ); +} + +function CallJoinedContent({ snapshot }: { snapshot: DmOngoingCallTileViewSnapshot }): React.ReactNode { + const { translate: _t } = useI18n(); + return ( + <> + +
{_t("timeline|call_tile|ongoing|dm|title")}
+ + ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/__snapshots__/DmOngoingCallTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/__snapshots__/DmOngoingCallTileView.test.tsx.snap new file mode 100644 index 00000000000..17735a29a72 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/dm/__snapshots__/DmOngoingCallTileView.test.tsx.snap @@ -0,0 +1,603 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`DmOngoingCallTileView > renders the tile > IncomingVideoCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Bob started a call +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > IncomingVoiceCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Bob started a call +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > OutgoingVideoCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Call started +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > OutgoingVoiceCall 1`] = ` +
+
+
+
+ + + +
+
+ + + +
+ Call started +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > VideoCallInProgress 1`] = ` +
+
+
+
+ + + +
+
+
+ + + + + + +
+
+ Call in progress +
+
+
+
+ ( + + ) +
+
+
+
+
+`; + +exports[`DmOngoingCallTileView > renders the tile > VoiceCallInProgress 1`] = ` +
+
+
+
+ + + +
+
+
+ + + + + + +
+
+ Call in progress +
+
+
+
+ ( + + ) +
+
+
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.module.css b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.module.css new file mode 100644 index 00000000000..e0fb3453ad5 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.module.css @@ -0,0 +1,19 @@ +/* + * 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. + */ + +.subContainer { + min-height: 23px; + width: 100%; + font: var(--cpd-font-body-md-regular); + color: var(--cpd-color-text-secondary); +} + +.startedTextContainer { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx new file mode 100644 index 00000000000..83ecbb7ef5f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.stories.tsx @@ -0,0 +1,97 @@ +/* + * 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 React from "react"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { type RoomOngoingCallTileViewSnapshot, RoomOngoingCallTileView } from "./RoomOngoingCallTileView"; +import { MockViewModel, useMockedViewModel } from "../../../../../../core/viewmodel"; +import { withViewDocs } from "../../../../../../../.storybook/withViewDocs"; +import { type CommonOngoingCallTileViewAction } from "../common"; +import { CallDirection } from "../../common"; +import profileLink1 from "../../../../../../../static/profile-pics/profile1.png"; +import profileLink2 from "../../../../../../../static/profile-pics/profile2.png"; +import profileLink3 from "../../../../../../../static/profile-pics/profile3.png"; +import { MockFacePileViewModel, MockMemberAvatarViewModel } from "../call-mocks"; + +const RoomOngoingCallTileViewWrapperImpl = ({ + join, + ...rest +}: RoomOngoingCallTileViewSnapshot & CommonOngoingCallTileViewAction): React.ReactNode => { + const vm = useMockedViewModel(rest, { join }); + return ; +}; + +const RoomOngoingCallTileViewWrapper = withViewDocs(RoomOngoingCallTileViewWrapperImpl, RoomOngoingCallTileView); + +const meta = { + title: "Timeline/Timeline Event/Call/Ongoing/RoomOngoingCallTileView", + component: RoomOngoingCallTileViewWrapper, + tags: ["autodocs"], + argTypes: { + callDirection: { + options: [CallDirection.Incoming, CallDirection.Outgoing], + control: { type: "radio" }, + }, + isCallIgnored: { + control: { type: "boolean" }, + }, + }, + args: { + callDirection: CallDirection.Incoming, + startedByDisplayName: "Bob", + durationViewModel: new MockViewModel({ duration: 100 }), + isCallIgnored: false, + totalParticipants: 8, + callHasOtherParticipants: true, + isJoinable: true, + isJoined: false, + join: () => {}, + memberAvatarViewModel: new MockMemberAvatarViewModel({ id: "@bob:m.org", name: "Bob", url: profileLink1 }), + facePileViewModel: new MockFacePileViewModel([ + { id: "@bob:m.org", name: "Bob", url: profileLink1 }, + { id: "@riley:m.org", name: "Riley", url: profileLink2 }, + { id: "@andi:m.org", name: "Andi", url: profileLink3 }, + { id: "@foo1:m.org", name: "Foo 1" }, + { id: "@foo2:m.org", name: "Foo 2" }, + { id: "@foo3:m.org", name: "Foo 3" }, + { id: "@foo4:m.org", name: "Foo 4" }, + { id: "@foo5:m.org", name: "Foo 5" }, + ]), + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/rTaQE2nIUSLav4Tg3nozq7/Compound-Web-Components?node-id=11217-3901&t=OvT1LOc5wH4kXt0a-4", + }, + }, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const RoomCallWithoutOtherParticipants: Story = { + args: { + callHasOtherParticipants: false, + totalParticipants: 1, + }, +}; + +export const RoomCallIgnored: Story = { + args: { + isCallIgnored: true, + }, +}; + +export const RoomCallJoined: Story = { + args: { + isJoined: true, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.test.tsx new file mode 100644 index 00000000000..9359c4c0bc9 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.test.tsx @@ -0,0 +1,34 @@ +/* + * 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 { composeStories } from "@storybook/react-vite"; +import { describe, expect, it } from "vitest"; +import React from "react"; +import { render } from "@test-utils"; + +import * as Stories from "./RoomOngoingCallTileView.stories"; + +const { RoomCallIgnored, RoomCallJoined, RoomCallWithoutOtherParticipants } = composeStories(Stories); + +describe("RoomOngoingCallTileView", () => { + describe("renders the tile", () => { + it("RoomCallIgnored", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("RoomCallJoined", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("RoomCallWithoutOtherParticipants", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.tsx b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.tsx new file mode 100644 index 00000000000..e434a3957a2 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/RoomOngoingCallTileView.tsx @@ -0,0 +1,109 @@ +/* + * 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 React from "react"; + +import { useViewModel, type ViewModel } from "../../../../../../core/viewmodel"; +import { TileContainer } from "../components/TileContainer/TileContainer"; +import { CallIcon } from "../components/CallIcon/CallIcon"; +import { CallType } from "../../common"; +import { Flex } from "../../../../../../core/utils/Flex"; +import commonStyles from "../common.module.css"; +import styles from "./RoomOngoingCallTileView.module.css"; +import { JoinButton } from "../components/JoinButton/JoinButton"; +import { DurationView } from "../components/Duration/DurationView"; +import { MemberAvatarView } from "../../../../../../core/MemberAvatar/MemberAvatarView"; +import { FacePileView } from "../../../../../../core/FacePile/FacePileView"; +import type { CommonOngoingCallTileViewAction, CommonOngoingCallTileViewSnapshot } from "../common"; +import { useI18n } from "../../../../../../core/i18n/i18nContext"; + +export interface RoomOngoingCallTileViewSnapshot extends CommonOngoingCallTileViewSnapshot { + /** + * The total number of participants in this call. + */ + totalParticipants: number; + + /** + * Whether the user ignored this call. + */ + isCallIgnored?: boolean; +} + +export type RoomCallStartedTileViewModel = ViewModel & CommonOngoingCallTileViewAction; + +interface Props { + vm: RoomCallStartedTileViewModel; +} + +/** + * View that renders the tile content for an ongoing call in a room. + */ +export function RoomOngoingCallTileView(props: Props): React.ReactNode { + const snapshot = useViewModel(props.vm); + const { isJoinable, isJoined, isCallIgnored, callHasOtherParticipants, durationViewModel } = snapshot; + const { translate: _t } = useI18n(); + return ( + + + + +
{_t("timeline|call_tile|ongoing|room|title")}
+ + {callHasOtherParticipants || isCallIgnored ? ( + + ) : ( + + )} +
+ + {durationViewModel && } + {!isJoined && ( + { + props.vm.join(event); + }} + /> + )} + +
+
+ ); +} + +/** + * This is the content of the tile when the call has participants other than who started the call or + * the call was ignored (by clicking decline button on the toast). + */ +function CallJoinedOrIgnoredContent({ snapshot }: { snapshot: RoomOngoingCallTileViewSnapshot }): React.ReactNode { + const { facePileViewModel, totalParticipants } = snapshot; + const { translate: _t } = useI18n(); + const joinedCount = totalParticipants; + return ( + + + {_t("timeline|call_tile|ongoing|room|join_count", { joinedCount })} + + ); +} + +/** + * This is the content of the tile when the call has just started. + */ +function CallStartedContent({ snapshot }: { snapshot: RoomOngoingCallTileViewSnapshot }): React.ReactNode { + const { memberAvatarViewModel, startedByDisplayName } = snapshot; + const { translate: _t } = useI18n(); + return ( + + +
+ {_t("timeline|call_tile|ongoing|common|call_started_by", { startedByDisplayName })} +
+
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/__snapshots__/RoomOngoingCallTileView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/__snapshots__/RoomOngoingCallTileView.test.tsx.snap new file mode 100644 index 00000000000..e0ac849d585 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/call/ongoing/room/__snapshots__/RoomOngoingCallTileView.test.tsx.snap @@ -0,0 +1,385 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`RoomOngoingCallTileView > renders the tile > RoomCallIgnored 1`] = ` +
+
+
+
+ + + +
+
+
+ Group call in progress +
+
+
+ + + + + + + + + +
+ 8 joined +
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; + +exports[`RoomOngoingCallTileView > renders the tile > RoomCallJoined 1`] = ` +
+
+
+
+ + + +
+
+
+ Group call in progress +
+
+
+ + + + + + + + + +
+ 8 joined +
+
+
+
+ ( + + ) +
+
+
+
+
+`; + +exports[`RoomOngoingCallTileView > renders the tile > RoomCallWithoutOtherParticipants 1`] = ` +
+
+
+
+ + + +
+
+
+ Group call in progress +
+
+ + + +
+ Bob started a call +
+
+
+
+
+ ( + + ) +
+ +
+
+
+
+`; diff --git a/packages/shared-components/static/profile-pics/profile1.png b/packages/shared-components/static/profile-pics/profile1.png new file mode 100644 index 00000000000..94c52f3f83a Binary files /dev/null and b/packages/shared-components/static/profile-pics/profile1.png differ diff --git a/packages/shared-components/static/profile-pics/profile2.png b/packages/shared-components/static/profile-pics/profile2.png new file mode 100644 index 00000000000..bb43ae7ca26 Binary files /dev/null and b/packages/shared-components/static/profile-pics/profile2.png differ diff --git a/packages/shared-components/static/profile-pics/profile3.png b/packages/shared-components/static/profile-pics/profile3.png new file mode 100644 index 00000000000..95c06110084 Binary files /dev/null and b/packages/shared-components/static/profile-pics/profile3.png differ