Skip to content
Open
Original file line number Diff line number Diff line change
@@ -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);
});
});
63 changes: 63 additions & 0 deletions apps/web/src/components/viewmodels/avatars/FacePileViewModel.ts
Original file line number Diff line number Diff line change
@@ -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<FacePileViewSnapshot, Props> {
private viewModelMap: Set<MemberAvatarViewModel> = new Set();

public constructor(props: Props) {
super(props, { memberAvatarViewModels: [] });
this.computeSnapshot();
}

private computeSnapshot(): void {
for (const vm of this.viewModelMap.values()) {
vm.dispose();
}
this.viewModelMap = new Set();
const members = this.props.members.slice(0, 3);
for (const member of members) {
const vm = this.disposables.track(
new MemberAvatarViewModel({ size: this.props.size, member, cli: this.props.cli }),
);
this.viewModelMap.add(vm);
}
this.snapshot.set({ memberAvatarViewModels: Array.from(this.viewModelMap.values()) });
}

/**
* Update the room-members from which the face-pile is rendered.
* @param newMembers The list of new room members
*/
public updateMembers(newMembers: RoomMember[]): void {
this.props.members = newMembers;
this.computeSnapshot();
}
}
Original file line number Diff line number Diff line change
@@ -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");
});
});
Original file line number Diff line number Diff line change
@@ -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),

Check warning on line 43 in apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9Dfdo27NBCKHeISufB&open=AZ9Dfdo27NBCKHeISufB&pullRequest=34198
parseInt(size, 10),

Check warning on line 44 in apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `Number.parseInt` over `parseInt`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9Dfdo27NBCKHeISufC&open=AZ9Dfdo27NBCKHeISufC&pullRequest=34198
"crop",
) ?? undefined;
}
return {
name,
id,
url,
size: `${size}px`,
title,
};
}

/**
* A view-model for rendering the avatar for a given room member.
*/
export class MemberAvatarViewModel extends BaseViewModel<MemberAvatarViewSnapshot, Props> {
public constructor(props: Props) {
super(props, computeSnapshot(props));
const roomId = props.member.roomId;
const room = props.cli.getRoom(roomId);
if (room) {
this.disposables.trackListener(room, RoomStateEvent.Members, this.onUpdate as (...args: unknown[]) => void);
}
}

private onUpdate = (event: unknown, state: unknown, member: RoomMember): void => {

Check warning on line 70 in apps/web/src/components/viewmodels/avatars/MemberAvatarViewModel.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'onUpdate' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AZ9Dfdo27NBCKHeISufD&open=AZ9Dfdo27NBCKHeISufD&pullRequest=34198
if (member.userId === this.props.member.userId) {
const newSnapshot = computeSnapshot(this.props);
this.snapshot.set(newSnapshot);
}
};
}
17 changes: 17 additions & 0 deletions apps/web/src/contexts/SDKContextClass.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
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
Expand Down Expand Up @@ -72,6 +74,8 @@
protected _ResizeNotifier?: ResizeNotifier;
protected _MultiRoomViewStore?: MultiRoomViewStore;
protected _Notifier?: Notifier;
protected _CallStore?: CallStore;
protected _LatestRtcNotificationEventStore?: LatestRtcNotificationEventStore;

public constructor() {
SettingController.sdkContext = this;
Expand Down Expand Up @@ -203,6 +207,19 @@
return this._Notifier;
}

public get callStore(): CallStore {
this._CallStore ??= CallStore.instance;
return this._CallStore;

Check warning on line 212 in apps/web/src/contexts/SDKContextClass.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 210-212 are not covered by tests
}

public get latestRtcNotificationEventStore(): LatestRtcNotificationEventStore {
if (!this._LatestRtcNotificationEventStore) {
this._LatestRtcNotificationEventStore = new LatestRtcNotificationEventStore(this.callStore);
this._LatestRtcNotificationEventStore.start();

Check warning on line 218 in apps/web/src/contexts/SDKContextClass.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 215-218 are not covered by tests
}
return this._LatestRtcNotificationEventStore;

Check warning on line 220 in apps/web/src/contexts/SDKContextClass.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 220 is not covered by tests
}

public onLoggedOut(): void {
this._UserProfilesStore = undefined;
this._client = undefined;
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/events/EventTileFactory.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
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,
Expand Down Expand Up @@ -57,6 +57,7 @@
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<
Expand Down Expand Up @@ -188,13 +189,16 @@
const RoomAvatarEventFactory: Factory = (ref, props) => <RoomAvatarEventWrappedView ref={ref} {...props} />;

function CallStartedTileViewWrapped({ mxEvent, getRelationsForEvent }: IBodyProps): JSX.Element {
const sdkContext = useContext(SDKContext);

Check warning on line 192 in apps/web/src/events/EventTileFactory.tsx

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Line 192 is not covered by tests
const cli = useMatrixClientContext();
const vm = useCreateAutoDisposedViewModel(
() =>
new RootCallTileViewModel({
mxEvent,
getRelationsForEvent,
cli,
callStore: sdkContext.callStore,
latestRtcNotificationEventStore: sdkContext.latestRtcNotificationEventStore,
}),
);
return <RootCallTileView vm={vm} />;
Expand Down
8 changes: 8 additions & 0 deletions apps/web/src/stores/CallStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,14 @@
return call !== null && this.connectedCalls.has(call) ? call : null;
}

/**
* Get all the ongoing calls
* @returns Map from room-id to the ongoing call in that room
*/
public getAllCalls(): Map<string, Call> {
return this.calls;

Check warning on line 218 in apps/web/src/stores/CallStore.ts

View workflow job for this annotation

GitHub Actions / Tests

Uncovered Line

Lines 217-218 are not covered by tests
}

private onWidgets = (roomId: string | null): void => {
if (!this.matrixClient) return;
if (roomId === null) {
Expand Down
Loading
Loading