Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
049d6a7
Add a common view snapshot type
MidhunSureshR Jul 8, 2026
2cf457e
Implement the common join button component
MidhunSureshR Jul 8, 2026
12bde83
Implement the call icon component
MidhunSureshR Jul 8, 2026
0b87ef5
Implement the common container component
MidhunSureshR Jul 8, 2026
9bea4ff
Support single digit minutes in existing clock component
MidhunSureshR Jul 8, 2026
2f93e1a
Implement the view for duration component
MidhunSureshR Jul 8, 2026
6e851fb
Add static profile pics to be used in storybook tests
MidhunSureshR Jul 8, 2026
f62ee82
Implement view for member avatar component
MidhunSureshR Jul 8, 2026
77d37a7
Implement view for face pile component
MidhunSureshR Jul 8, 2026
2882e73
Implement RoomOngoingCallTileView
MidhunSureshR Jul 8, 2026
b1e93fe
Implement DmOngoingCallTileView
MidhunSureshR Jul 8, 2026
cfcdea2
Support the new tiles in RootTileView
MidhunSureshR Jul 8, 2026
00ec32b
Export all the views
MidhunSureshR Jul 8, 2026
3a887e4
Update all the screenshots
MidhunSureshR Jul 8, 2026
4cbe41b
Implement view model for duration view
MidhunSureshR Jul 8, 2026
b6399bf
Implement vm for face pile view
MidhunSureshR Jul 8, 2026
84bfaa5
Implement vm for member avatar view
MidhunSureshR Jul 8, 2026
ca9f4fe
Expose call store via the sdk context
MidhunSureshR Jul 8, 2026
c838ea7
Add method to get all calls in store
MidhunSureshR Jul 8, 2026
5989196
Add store to track latest notification event per room
MidhunSureshR Jul 8, 2026
cc3d74a
Also expose new store via sdk context
MidhunSureshR Jul 8, 2026
c5fed49
Create a base vm for both tiles
MidhunSureshR Jul 8, 2026
b59f300
Implement the room ongoing call tile vm
MidhunSureshR Jul 8, 2026
bd3f0ca
Implement the dm ongoing call tile vm
MidhunSureshR Jul 8, 2026
63aa6ca
Support new tile vms in the root tile vm
MidhunSureshR Jul 8, 2026
ea45cd0
Remove unused code
MidhunSureshR Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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=AZ9CDAC83wYsaW3iFYQp&open=AZ9CDAC83wYsaW3iFYQp&pullRequest=34188
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=AZ9CDAC83wYsaW3iFYQq&open=AZ9CDAC83wYsaW3iFYQq&pullRequest=34188
"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=AZ9CDAC83wYsaW3iFYQr&open=AZ9CDAC83wYsaW3iFYQr&pullRequest=34188
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