7000 loc COPIED from element call

They are copied for now but will be removed from element call once it
adopts the sdk. Those are no real line additions.
This commit is contained in:
Timo K.
2026-10-08 13:53:56 +02:00
parent 61e353d919
commit 31eafb394b
58 changed files with 7330 additions and 204 deletions
+133
View File
@@ -0,0 +1,133 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test } from "vitest";
import { calculateDisplayName, shouldDisambiguate } from "./displayname";
import {
alice,
aliceDoppelganger,
aliceDoppelgangerRtcMember,
aliceRtcMember,
bob,
bobRtcMember,
bobZeroWidthSpace,
bobZeroWidthSpaceRtcMember,
daveRTL,
} from "./test-fixtures";
import { mockMatrixRoom } from "./test";
import { roomToMembersMap } from "../session/MatrixMemberMetadata";
describe("shouldDisambiguate", () => {
test("should not disambiguate a solo member", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [],
});
expect(shouldDisambiguate(alice, [], roomToMembersMap(room))).toEqual(
false,
);
});
test("should not disambiguate a member with an empty displayname", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [alice, aliceDoppelganger],
});
expect(
shouldDisambiguate(
{ rawDisplayName: "", userId: alice.userId },
[aliceRtcMember, aliceDoppelgangerRtcMember],
roomToMembersMap(room),
),
).toEqual(false);
});
test("should disambiguate a member with RTL characters", () => {
const room = mockMatrixRoom({ getMembersWithMembership: () => [] });
expect(shouldDisambiguate(daveRTL, [], roomToMembersMap(room))).toEqual(
true,
);
});
test("should disambiguate a member with a matching displayname", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [alice, aliceDoppelganger],
});
expect(
shouldDisambiguate(
alice,
[aliceRtcMember, aliceDoppelgangerRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
expect(
shouldDisambiguate(
aliceDoppelganger,
[aliceRtcMember, aliceDoppelgangerRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
});
test("should disambiguate a member with a matching displayname with hidden spaces", () => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [bob, bobZeroWidthSpace],
});
expect(
shouldDisambiguate(
bob,
[bobRtcMember, bobZeroWidthSpaceRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
expect(
shouldDisambiguate(
bobZeroWidthSpace,
[bobRtcMember, bobZeroWidthSpaceRtcMember],
roomToMembersMap(room),
),
).toEqual(true);
});
test.for(["Alice @foo:bar", "@foo:b", "A@foo:lice", "A @f oo: ba r"])(
"should disambiguate a member with a displayname containing a mxid-like string '%s'",
(rawDisplayName) => {
const room = mockMatrixRoom({
getMembersWithMembership: () => [alice, aliceDoppelganger],
});
expect(
shouldDisambiguate(
{ rawDisplayName, userId: alice.userId },
[],
roomToMembersMap(room),
),
).toEqual(true);
},
);
});
describe("calculateDisplayName", () => {
test.for<[{ rawDisplayName?: string; userId: string }, boolean, string]>([
[alice, false, alice.rawDisplayName],
[alice, true, `${alice.rawDisplayName} (${alice.userId})`],
// Empty strings and zero width strings that are effectively empty are resolved as userIds
[{ rawDisplayName: "", userId: alice.userId }, false, alice.userId],
[
{ rawDisplayName: "\u200b\u200b\u200b", userId: alice.userId },
false,
alice.userId,
],
[
{ rawDisplayName: alice.userId, userId: alice.userId },
false,
alice.userId,
],
// Zero width strings are kept intact
[bobZeroWidthSpace, false, bobZeroWidthSpace.rawDisplayName],
// Directional characters are stripped.
[daveRTL, false, daveRTL.rawDisplayName.slice(1)],
[daveRTL, true, `${daveRTL.rawDisplayName.slice(1)} (${daveRTL.userId})`],
// Ensure we do NOT unhomoglyth
[{ ...alice, rawDisplayName: "alice m" }, false, "alice m"],
])("correctly calculates displayname", ([member, disambiguate, result]) =>
expect(calculateDisplayName(member, disambiguate)).toEqual(result),
);
});
+114
View File
@@ -0,0 +1,114 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
removeDirectionOverrideChars,
removeHiddenChars as removeHiddenCharsUncached,
} from "matrix-js-sdk/lib/utils";
import type { RoomMember } from "matrix-js-sdk";
import type { CallMembership } from "matrix-js-sdk/lib/matrixrtc";
// Calling removeHiddenChars() can be slow on Safari, so we cache the results.
// To illustrate a simple benchmark:
// Chrome: 10,000 calls took 2.599ms
// Safari: 10,000 calls took 242ms
// See: https://github.com/element-hq/element-call/issues/3065
const removeHiddenCharsCache = new Map<string, string>();
/**
* Calls removeHiddenCharsUncached and caches the result
*/
function removeHiddenChars(str: string): string {
if (removeHiddenCharsCache.has(str)) {
return removeHiddenCharsCache.get(str)!;
}
const result = removeHiddenCharsUncached(str);
// this is naive but should be good enough for our purposes
if (removeHiddenCharsCache.size > 500) {
removeHiddenCharsCache.clear();
}
removeHiddenCharsCache.set(str, result);
return result;
}
// Borrowed from https://github.com/matrix-org/matrix-js-sdk/blob/f10deb5ef2e8f061ff005af0476034382ea128ca/src/models/room-member.ts#L409
export function shouldDisambiguate(
member: { rawDisplayName?: string; userId: string },
memberships: Pick<CallMembership, "userId">[],
roomMembers: Map<string, Pick<RoomMember, "userId">>,
): boolean {
const { rawDisplayName: displayName, userId } = member;
if (!displayName || displayName === userId) return false;
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
const strippedDisplayName = removeHiddenChars(displayName);
if (!strippedDisplayName) return false;
// Next check if the name contains something that look like a mxid
// If it does, it may be someone trying to impersonate someone else
// Show full mxid in this case
if (/@.+:.+/.test(displayName)) return true;
// Also show mxid if the display name contains any LTR/RTL characters as these
// make it very difficult for us to find similar *looking* display names
// E.g "Mark" could be cloned by writing "kraM" but in RTL.
if (/[\u200E\u200F\u202A-\u202F]/.test(displayName)) return true;
// Also show mxid if there are other people with the same or similar
// displayname, after hidden character removal.
return (
memberships
.map((m) => m.userId && roomMembers.get(m.userId))
// NOTE: We *should* have a room member for everyone.
.filter((m) => !!m)
.filter((m) => m.userId !== userId)
.some(
(m) =>
removeHiddenChars(calculateDisplayName(m, false)) ===
strippedDisplayName,
)
);
}
/**
* Calculates a display name for a member, optionally disambiguating it.
* @param member - The member to calculate the display name for.
* @param member.rawDisplayName - The raw display name of the member
* @param member.userId - The user ID of the member
* @param disambiguate - Whether to disambiguate the display name.
*/
export function calculateDisplayName(
member: { rawDisplayName?: string; userId: string },
disambiguate: boolean,
): string {
const { rawDisplayName: displayName, userId } = member;
if (!displayName || displayName === userId) return userId;
const resultDisplayname = removeDirectionOverrideChars(displayName);
if (disambiguate) return resultDisplayname + " (" + userId + ")";
// First check if the displayname is something we consider truthy
// after stripping it of zero width characters and padding spaces
if (!removeHiddenChars(displayName)) return userId;
// We always strip the direction override characters (LRO and RLO).
// These override the text direction for all subsequent characters
// in the paragraph so if display names contained these, they'd
// need to be wrapped in something to prevent this from leaking out
// (which we can do in HTML but not text) or we'd need to add
// control characters to the string to reset any overrides (eg.
// adding PDF characters at the end). As far as we can see,
// there should be no reason these would be necessary - rtl display
// names should flip into the correct direction automatically based on
// the characters, and you can still embed rtl in ltr or vice versa
// with the embed chars or marker chars.
return resultDisplayname;
}
-35
View File
@@ -1,35 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { MatrixRTCError } from "../api";
import { toMatrixRTCError } from "./errors";
describe("toMatrixRTCError", () => {
it("keeps an MatrixRTCError as it is", () => {
const error = new MatrixRTCError("gone");
expect(toMatrixRTCError(error)).toBe(error);
});
it("wraps another error as the cause", () => {
const cause = new Error("boom");
const error = toMatrixRTCError(cause);
expect(error).toBeInstanceOf(MatrixRTCError);
expect(error.message).toBe("boom");
expect(error.cause).toBe(cause);
});
it("falls back to the code of an error without a message", () => {
const cause = Object.assign(new Error(""), { code: "SFU_ERROR" });
expect(toMatrixRTCError(cause).message).toBe("SFU_ERROR");
});
it("stringifies anything else", () => {
expect(toMatrixRTCError("nope").message).toBe("nope");
});
});
-27
View File
@@ -1,27 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { MatrixRTCError } from "../api";
/** Wraps whatever a backend threw into the SDK's error type, keeping it as the cause. */
export function toMatrixRTCError(error: unknown): MatrixRTCError {
if (error instanceof MatrixRTCError) return error;
if (error instanceof Error)
return new MatrixRTCError(messageOf(error), { cause: error });
return new MatrixRTCError(String(error));
}
/**
* Element Call's errors carry a translated title as their message, which is
* empty where no translations are loaded; their code says what went wrong
* regardless.
*/
function messageOf(error: Error): string {
if (error.message !== "") return error.message;
if ("code" in error && typeof error.code === "string") return error.code;
return error.name;
}
+1 -1
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, it, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { ObservableScope } from "../../../src/state/ObservableScope";
import { ObservableScope } from "../reactive/ObservableScope";
import { mapScoped } from "./mapScoped";
describe("mapScoped", () => {
+2 -2
View File
@@ -7,8 +7,8 @@ Please see LICENSE in the repository root for full details.
import { Observable, of, switchMap } from "rxjs";
import { type Behavior } from "../../../src/state/Behavior";
import { ObservableScope } from "../../../src/state/ObservableScope";
import { type Behavior } from "../reactive/Behavior";
import { ObservableScope } from "../reactive/ObservableScope";
/**
* Builds one item per present value of a behavior, each in a scope of its own
+31
View File
@@ -0,0 +1,31 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { calculateRetryBackoff } from "matrix-js-sdk";
import { sleep } from "matrix-js-sdk/lib/utils";
/**
* Performs a network operation with retries on connection errors, honouring
* the homeserver's rate limits. Rethrows once the error is not retryable or
* the retries are used up.
*/
export async function doNetworkOperationWithRetry<T>(
operation: () => Promise<T>,
): Promise<T> {
let currentRetryCount = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await operation();
} catch (e) {
currentRetryCount++;
const backoff = calculateRetryBackoff(e, currentRetryCount, true);
if (backoff < 0) throw e;
await sleep(backoff);
}
}
}
+65
View File
@@ -0,0 +1,65 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { mockRtcMembership, mockMatrixRoomMember } from "./test";
export const localRtcMember = mockRtcMembership("@local:example.org", "1111");
export const aliceDeviceId = "AAAA";
export const aliceUserId = "@alice:example.org";
export const aliceRtcMember = mockRtcMembership(aliceUserId, aliceDeviceId);
export const alice = mockMatrixRoomMember(aliceRtcMember, {
rawDisplayName: "Alice",
});
export const aliceDoppelgangerRtcMember = mockRtcMembership(
"@alice2:example.org",
"AAAA",
);
export const aliceDoppelganger = mockMatrixRoomMember(
aliceDoppelgangerRtcMember,
{
rawDisplayName: "Alice",
},
);
export const bobDeviceId = "BBBB";
export const bobUserId = "@bob:example.org";
export const bobRtcMember = mockRtcMembership(bobUserId, bobDeviceId);
export const bob = mockMatrixRoomMember(bobRtcMember, {
rawDisplayName: "Bob",
});
export const bobZeroWidthSpaceRtcMember = mockRtcMembership(
"@bob2:example.org",
"BBBB",
);
export const bobZeroWidthSpace = mockMatrixRoomMember(
bobZeroWidthSpaceRtcMember,
{
rawDisplayName: "Bo\u200bb",
},
);
export const daveRTLRtcMember = mockRtcMembership("@dave2:example.org", "DDDD");
export const daveRTL = mockMatrixRoomMember(daveRTLRtcMember, {
rawDisplayName: "\u202eevaD",
});
export const testJWTToken = [
{}, // header
{
// payload
sub: "@me:example.org:ABCDEF",
video: {
room: "!example_room_id",
},
},
{}, // signature
]
.map((d) => global.btoa(JSON.stringify(d)))
.join(".");
+342
View File
@@ -0,0 +1,342 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/** Test helpers shared by the SDK's unit tests. Not part of the package. */
import { map, type SchedulerLike } from "rxjs";
import { type RunHelpers, TestScheduler } from "rxjs/testing";
import { expect, type MockedObject, onTestFinished, vi, vitest } from "vitest";
import {
EventType,
MatrixEvent,
type Room,
type Room as MatrixRoom,
type RoomMember,
} from "matrix-js-sdk";
import {
CallMembership,
type LivekitFocusSelection,
type LivekitTransport,
type MatrixRTCSession,
MatrixRTCSessionEvent,
type MatrixRTCSessionEventHandlerMap,
MembershipManagerEvent,
type SessionMembershipData,
Status,
type Transport,
} from "matrix-js-sdk/lib/matrixrtc";
import { type MembershipManagerEventHandlerMap } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
import {
type KeyTransportEvents,
type KeyTransportEventsHandlerMap,
} from "matrix-js-sdk/lib/matrixrtc/IKeyTransport";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { TypedEventEmitter } from "matrix-js-sdk/lib/models/typed-event-emitter";
import {
type RemoteParticipant,
type RemoteTrackPublication,
} from "livekit-client";
import { randomUUID } from "crypto";
import EventEmitter from "events";
import { type Behavior } from "../reactive/Behavior";
import { ObservableScope } from "../reactive/ObservableScope";
export async function flushPromises(): Promise<void> {
await new Promise<void>((resolve) => window.setTimeout(resolve));
}
export interface OurRunHelpers extends RunHelpers {
/**
* Schedules a sequence of actions to happen, as described by a marble
* diagram.
*/
schedule: (marbles: string, actions: Record<string, () => void>) => void;
behavior: <T>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
) => Behavior<T>;
scope: ObservableScope;
}
interface TestRunnerGlobal {
rxjsTestScheduler?: SchedulerLike;
}
/**
* Create a new ObservableScope which ends when the current test ends.
*/
export function testScope(): ObservableScope {
const scope = new ObservableScope();
onTestFinished(() => scope.end());
return scope;
}
/**
* Run Observables with a scheduler that virtualizes time, for testing purposes.
*/
export function withTestScheduler(
continuation: (helpers: OurRunHelpers) => void,
): void {
const scheduler = new TestScheduler((actual, expected) => {
expect(actual).toStrictEqual(expected);
});
const scope = new ObservableScope();
// we set the test scheduler as a global so that you can watch it in a debugger
// and get the frame number. e.g. `rxjsTestScheduler?.now()`
(global as unknown as TestRunnerGlobal).rxjsTestScheduler = scheduler;
scheduler.run((helpers) =>
continuation({
...helpers,
scope,
schedule(marbles, actions) {
const actionsObservable$ = helpers
.cold(marbles)
.pipe(map((value) => actions[value]()));
const results = Object.fromEntries(
Object.keys(actions).map((value) => [value, undefined] as const),
);
// Run the actions and verify that none of them error
helpers.expectObservable(actionsObservable$).toBe(marbles, results);
},
behavior<T>(
marbles: string,
values?: { [marble: string]: T },
error?: unknown,
) {
// Generate a hot Observable with helpers.hot and use it as a Behavior.
// To do this, we need to ensure that the initial value emits
// synchronously upon subscription. The issue is that helpers.hot emits
// frame 0 of the marble diagram *asynchronously*, only once we return
// from the continuation, so we need to splice out the initial marble
// and turn it into a proper initial value.
const initialMarbleIndex = marbles.search(/[^ ]/);
if (initialMarbleIndex === -1)
throw new Error("Behavior must have an initial value");
const initialMarble = marbles[initialMarbleIndex];
const initialValue =
values === undefined ? (initialMarble as T) : values[initialMarble];
// The remainder of the marble diagram should start on frame 1
return scope.behavior(
helpers.hot(
`-${marbles.slice(initialMarbleIndex + 1)}`,
values,
error,
),
initialValue,
);
},
}),
);
scope.end();
}
interface EmitterMock<T> {
on: (...args: unknown[]) => T;
off: (...args: unknown[]) => T;
addListener: (...args: unknown[]) => T;
removeListener: (...args: unknown[]) => T;
emit: (event: string | symbol, ...args: unknown[]) => boolean;
}
export function mockEmitter<T>(): EmitterMock<T> {
const ee = new EventEmitter();
return {
on: ee.on.bind(ee) as unknown as (...args: unknown[]) => T,
off: ee.off.bind(ee) as unknown as (...args: unknown[]) => T,
addListener: ee.addListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
removeListener: ee.removeListener.bind(ee) as unknown as (
...args: unknown[]
) => T,
emit: ee.emit.bind(ee),
};
}
export const exampleTransport: LivekitTransport = {
type: "livekit",
livekit_service_url: "https://lk.example.org",
};
export function mockRtcMembership(
user: string | RoomMember,
deviceId: string,
customOverwrites?: {
rtcBackendIdentity?: string;
callId?: string;
fociPreferred?: Transport[];
focusActive?: LivekitFocusSelection;
membership?: Partial<SessionMembershipData>;
},
): CallMembership {
// setup defaults based on overwrites and fallback values.
const { rtcBackendIdentity, callId, fociPreferred, focusActive, membership } =
{
fociPreferred: [exampleTransport],
focusActive: {
type: "livekit" as const,
focus_selection: "multi_sfu" as const,
},
callId: "",
membership: {},
...customOverwrites,
};
const data: SessionMembershipData = {
application: "m.call",
call_id: callId,
device_id: deviceId,
foci_preferred: fociPreferred,
focus_active: focusActive,
...membership,
};
const userId = typeof user === "string" ? user : user.userId;
const event = new MatrixEvent({
sender: userId,
event_id: `$-ev-${randomUUID()}:example.org`,
type: EventType.GroupCallMemberPrefix,
content: data,
});
const membershipData = CallMembership.membershipDataFromMatrixEvent(event);
const cms = new CallMembership(
event,
membershipData,
rtcBackendIdentity ?? `${userId}:${deviceId}`,
);
vi.mocked(cms).getTransport = vi.fn().mockReturnValue(fociPreferred[0]);
return cms;
}
export const ownMemberMock: CallMembershipIdentityParts = {
userId: "@alice:example.org",
deviceId: "DEVICE",
memberId: "@alice:example.org:DEVICE",
};
// Maybe it'd be good to move this to matrix-js-sdk? Our testing needs are
// rather simple, but if one util to mock a member is good enough for us, maybe
// it's useful for matrix-js-sdk consumers in general.
export function mockMatrixRoomMember(
rtcMembership: CallMembership,
member: Partial<RoomMember> = {},
): RoomMember {
return {
...mockEmitter(),
userId: rtcMembership.userId,
getMxcAvatarUrl(): string | undefined {
return undefined;
},
rawDisplayName: rtcMembership.userId,
...member,
} as RoomMember;
}
export function mockMatrixRoom(room: Partial<MatrixRoom>): MatrixRoom {
return { ...mockEmitter(), ...room } as Partial<MatrixRoom> as MatrixRoom;
}
export function mockRemoteParticipant(
participant: Partial<RemoteParticipant>,
): RemoteParticipant {
return {
isLocal: false,
setVolume() {},
getTrackPublication: () =>
({}) as Partial<RemoteTrackPublication> as RemoteTrackPublication,
// this will only get used for `getTrackPublications().length`
getTrackPublications: () => [0],
...mockEmitter(),
...participant,
} as RemoteParticipant;
}
export class MockRTCSession extends TypedEventEmitter<
MatrixRTCSessionEvent | MembershipManagerEvent | KeyTransportEvents,
KeyTransportEventsHandlerMap &
MatrixRTCSessionEventHandlerMap &
MembershipManagerEventHandlerMap
> {
public asMockedSession(): MockedObject<MatrixRTCSession> {
const session = this as unknown as MockedObject<MatrixRTCSession>;
session.reemitEncryptionKeys = vi
.fn<() => void>()
.mockReturnValue(undefined);
return session;
}
public readonly statistics = {
counters: {},
};
public leaveRoomSession: ReturnType<typeof vitest.fn> = vitest
.fn()
.mockResolvedValue(undefined);
public constructor(
public readonly room: Room,
public memberships: CallMembership[] = [],
) {
super();
}
public joined = true;
public isJoined(): boolean {
return this.joined;
}
public isKeyRotationSuppressed = false;
public withMemberships(
rtcMembers$: Behavior<Partial<CallMembership>[]>,
): MockRTCSession {
rtcMembers$.subscribe((m) => {
const old = this.memberships;
this.memberships = m as CallMembership[];
this.emit(
MatrixRTCSessionEvent.MembershipsChanged,
old,
this.memberships,
);
});
return this;
}
public updateCallIntent: ReturnType<typeof vitest.fn> = vitest
.fn()
.mockImplementation(async () => Promise.resolve());
private _membershipStatus = Status.Connected;
public get membershipStatus(): Status {
return this._membershipStatus;
}
public set membershipStatus(value: Status) {
const prev = this._membershipStatus;
this._membershipStatus = value;
if (value !== prev)
this.emit(MembershipManagerEvent.StatusChanged, prev, value);
}
private _probablyLeft = false;
public get probablyLeft(): boolean {
return this._probablyLeft;
}
public set probablyLeft(value: boolean) {
const prev = this._probablyLeft;
this._probablyLeft = value;
if (value !== prev) this.emit(MembershipManagerEvent.ProbablyLeft, value);
}
public async joinRTCSession(): Promise<void> {
return Promise.resolve();
}
}