Merge branch 'livekit' into toger5/dont-trap-in-invalid-config

This commit is contained in:
Timo K
2026-01-16 13:11:06 +01:00
15 changed files with 526 additions and 215 deletions

View File

@@ -99,6 +99,13 @@ module.exports = {
"jsdoc/require-param-description": "off",
},
},
{
files: ["playwright/**"],
rules: {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off",
},
},
],
settings: {
react: {

View File

@@ -3,7 +3,7 @@ networks:
services:
auth-service:
image: ghcr.io/element-hq/lk-jwt-service:pr_139
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
pull_policy: always
hostname: auth-server
environment:
@@ -25,7 +25,7 @@ services:
- ecbackend
auth-service-1:
image: ghcr.io/element-hq/lk-jwt-service:pr_139
image: ghcr.io/element-hq/lk-jwt-service:sha-f8ddd00
pull_policy: always
hostname: auth-server-1
environment:

View File

@@ -6,15 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type Browser,
type Page,
test,
expect,
type JSHandle,
} from "@playwright/test";
import { type Page, test, expect, type JSHandle } from "@playwright/test";
import type { MatrixClient } from "matrix-js-sdk";
import { TestHelpers } from "../widget/test-helpers.ts";
export type UserBaseFixture = {
mxId: string;
@@ -31,10 +26,11 @@ export type BaseWidgetSetup = {
export interface MyFixtures {
asWidget: BaseWidgetSetup;
callType: "room" | "dm";
addUser: (
username: string /**, homeserver: string*/,
) => Promise<UserBaseFixture>;
}
const PASSWORD = "foobarbaz1!";
// Minimal config.json for the local element-web instance
const CONFIG_JSON = {
default_server_config: {
@@ -68,85 +64,6 @@ const CONFIG_JSON = {
},
};
/**
* Set the Element Call URL in the dev tool settings using `window.mxSettingsStore` via `page.evaluate`.
*/
const setDevToolElementCallDevUrl = process.env.USE_DOCKER
? async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"http://localhost:8080/room",
);
});
}
: async (page: Page): Promise<void> => {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
};
/**
* Registers a new user and returns page, clientHandle and mxId.
*/
async function registerUser(
browser: Browser,
username: string,
): Promise<{ page: Page; clientHandle: JSHandle<MatrixClient>; mxId: string }> {
const userContext = await browser.newContext({
reducedMotion: "reduce",
});
const page = await userContext.newPage();
await page.goto("http://localhost:8081/#/welcome");
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await page.getByRole("textbox", { name: "Confirm password" }).click();
await page.getByRole("textbox", { name: "Confirm password" }).fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click();
await expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible();
const browserUnsupportedToast = page
.getByText("Element does not support this browser")
.locator("..")
.locator("..");
// Dismiss incompatible browser toast
const dismissButton = browserUnsupportedToast.getByRole("button", {
name: "Dismiss",
});
try {
await expect(dismissButton).toBeVisible({ timeout: 700 });
await dismissButton.click();
} catch {
// dismissButton not visible, continue as normal
}
await setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
return { page, clientHandle, mxId };
}
export const widgetTest = test.extend<MyFixtures>({
// allow per-test override: `widgetTest.use({ callType: "dm" })`
callType: ["room", { option: true }],
@@ -163,25 +80,16 @@ export const widgetTest = test.extend<MyFixtures>({
page: ewPage1,
clientHandle: brooksClientHandle,
mxId: brooksMxId,
} = await registerUser(browser, brooksDisplayName);
} = await TestHelpers.registerUser(browser, brooksDisplayName);
const {
page: ewPage2,
clientHandle: whistlerClientHandle,
mxId: whistlerMxId,
} = await registerUser(browser, whistlerDisplayName);
} = await TestHelpers.registerUser(browser, whistlerDisplayName);
// Invite the second user
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
if (callType === "room") {
await ewPage1.getByRole("menuitem", { name: "New Room" }).click();
await ewPage1.getByRole("textbox", { name: "Name" }).fill("Welcome Room");
await ewPage1.getByRole("button", { name: "Create room" }).click();
await expect(ewPage1.getByText("You created this room.")).toBeVisible();
await expect(ewPage1.getByText("Encryption enabled")).toBeVisible();
await TestHelpers.createRoom("Welcome Room", ewPage1);
await ewPage1
.getByRole("button", { name: "Invite to this room", exact: true })
@@ -211,6 +119,11 @@ export const widgetTest = test.extend<MyFixtures>({
.getByRole("heading", { name: "Welcome Room" }),
).toBeVisible();
} else if (callType === "dm") {
await ewPage1
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await ewPage1.getByRole("menuitem", { name: "Start chat" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).click();
await ewPage1.getByRole("textbox", { name: "Search" }).fill(whistlerMxId);
@@ -253,4 +166,28 @@ export const widgetTest = test.extend<MyFixtures>({
},
});
},
/**
* Provide a way to add additional users within a test.
* The returned user will be registered on the default homeserver, the name will be made unique by appending a timestamp.
*/
addUser: async ({ browser }, use) => {
await use(
async (
username: string /**, homeserver?: string*/,
): Promise<UserBaseFixture> => {
const uniqueSuffix = Date.now();
const { page, clientHandle, mxId } = await TestHelpers.registerUser(
browser,
`${username.toLowerCase()}_${uniqueSuffix}`,
);
return {
mxId,
displayName: username,
page,
clientHandle,
};
},
);
},
});

View File

@@ -0,0 +1,160 @@
/*
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 { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { TestHelpers } from "./test-helpers.ts";
widgetTest("Create and join a group call", async ({ addUser, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // We are registering multiple users here, give it more time
const valere = await addUser("Valere");
const timo = await addUser("Timo");
const robin = await addUser("Robin");
const halfshot = await addUser("Halfshot");
const florian = await addUser("florian");
const roomName = "Group Call Room";
await TestHelpers.createRoom(roomName, valere.page, [
timo.mxId,
robin.mxId,
halfshot.mxId,
florian.mxId,
]);
for (const user of [timo, robin, halfshot, florian]) {
// Accept the invite
// This isn't super stable to get this as this super generic locator,
// but it works for now.
await expect(
user.page.getByRole("option", { name: roomName }),
).toBeVisible();
await user.page.getByRole("option", { name: roomName }).click();
await user.page.getByRole("button", { name: "Accept" }).click();
await expect(
user.page.getByRole("main").getByRole("heading", { name: roomName }),
).toBeVisible();
}
// Start the call as Valere
await TestHelpers.startCallInCurrentRoom(valere.page, false);
await expect(
valere.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
await expect(
valere.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall"),
).toBeVisible();
await valere.page
.locator('iframe[title="Element Call"]')
.contentFrame()
.getByTestId("lobby_joinCall")
.click();
for (const user of [timo, robin, halfshot, florian]) {
// THis is the header button that notifies about an ongoing call
await expect(user.page.getByText("Video call started")).toBeVisible();
await expect(user.page.getByRole("button", { name: "Join" })).toBeVisible();
await user.page.getByRole("button", { name: "Join" }).click();
}
for (const user of [timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// No lobby, should start with video on
// The only way to know if it is muted or not is to look at the data-kind attribute..
const videoButton = frame.getByTestId("incall_videomute");
await expect(videoButton).toBeVisible();
// video should be off by default in a voice call
await expect(videoButton).toHaveAttribute("aria-label", /^Stop video$/);
}
// We should see 5 video tiles everywhere now
for (const user of [valere, timo, robin, halfshot, florian]) {
const frame = user.page
.locator('iframe[title="Element Call"]')
.contentFrame();
await expect(frame.getByTestId("videoTile")).toHaveCount(5);
for (const participant of [valere, timo, robin, halfshot, florian]) {
// Check the names are correct
await expect(frame.getByText(participant.displayName)).toBeVisible();
}
// There is no other options than to wait for all media to be ready?
// Or it is too flaky :/
await user.page.waitForTimeout(5000);
// No one should be waiting for media
await expect(frame.getByText("Waiting for media...")).not.toBeVisible();
// There should be 5 video elements, visible and autoplaying
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
await expect(frame.locator("video[autoplay]")).toHaveCount(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
expect(blockDisplayCount).toBe(5);
}
// Quickly test muting one participant to see it reflects and that our asserts works
const florianFrame = florian.page
.locator('iframe[title="Element Call"]')
.contentFrame();
const florianMuteButton = florianFrame.getByTestId("incall_videomute");
await florianMuteButton.click();
// Now the button should indicate we can start video
await expect(florianMuteButton).toHaveAttribute(
"aria-label",
/^Start video$/,
);
// wait a bit for the state to propagate
await valere.page.waitForTimeout(3000);
{
const frame = valere.page
.locator('iframe[title="Element Call"]')
.contentFrame();
const videoElements = await frame.locator("video").all();
expect(videoElements.length).toBe(5);
const blockDisplayCount = await frame
.locator("video")
.evaluateAll(
(videos: Element[]) =>
videos.filter(
(v: Element) => window.getComputedStyle(v).display === "block",
).length,
);
// out of 5 ONLY 4 are visible (display:block) !!
// XXX we need to be better at our HTML markup and accessibility, it would make
// this kind of stuff way easier to test if we could look out for aria attributes.
expect(blockDisplayCount).toBe(4);
}
await valere.page.pause();
});

View File

@@ -0,0 +1,156 @@
/*
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 {
type Browser,
expect,
type JSHandle,
type Page,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
const PASSWORD = "foobarbaz1!";
export class TestHelpers {
public static async startCallInCurrentRoom(
page: Page,
voice: boolean = false,
): Promise<void> {
const buttonName = voice ? "Voice call" : "Video call";
await expect(page.getByRole("button", { name: buttonName })).toBeVisible();
await page.getByRole("button", { name: buttonName }).click();
await expect(
page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await page.getByRole("menuitem", { name: "Element Call" }).click();
}
/**
* Registers a new user and returns page, clientHandle and mxId.
*/
public static async registerUser(
browser: Browser,
username: string,
): Promise<{
page: Page;
clientHandle: JSHandle<MatrixClient>;
mxId: string;
}> {
const userContext = await browser.newContext({
reducedMotion: "reduce",
});
const page = await userContext.newPage();
await page.goto("http://localhost:8081/#/welcome");
await page.getByRole("link", { name: "Create Account" }).click();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page
.getByRole("textbox", { name: "Password", exact: true })
.fill(PASSWORD);
await page.getByRole("textbox", { name: "Confirm password" }).click();
await page
.getByRole("textbox", { name: "Confirm password" })
.fill(PASSWORD);
await page.getByRole("button", { name: "Register" }).click();
await expect(
page.getByRole("heading", { name: `Welcome ${username}` }),
).toBeVisible({
// Increase timeout as registration can be slow :/
timeout: 15_000,
});
const browserUnsupportedToast = page
.getByText("Element does not support this browser")
.locator("..")
.locator("..");
// Dismiss incompatible browser toast
const dismissButton = browserUnsupportedToast.getByRole("button", {
name: "Dismiss",
});
try {
await expect(dismissButton).toBeVisible({ timeout: 700 });
await dismissButton.click();
} catch {
// dismissButton not visible, continue as normal
}
await TestHelpers.setDevToolElementCallDevUrl(page);
const clientHandle = await page.evaluateHandle(() =>
window.mxMatrixClientPeg.get(),
);
const mxId = (await clientHandle.evaluate(
(cli: MatrixClient) => cli.getUserId(),
clientHandle,
))!;
return { page, clientHandle, mxId };
}
public static async createRoom(
name: string,
page: Page,
andInvite: string[] = [],
): Promise<void> {
await page.pause();
await page
.getByRole("navigation", { name: "Room list" })
.getByRole("button", { name: "New conversation" })
.click();
await page.getByRole("menuitem", { name: "New Room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill(name);
await page.getByRole("button", { name: "Create room" }).click();
await expect(page.getByText("You created this room.")).toBeVisible();
await expect(page.getByText("Encryption enabled")).toBeVisible();
// Invite users if any
if (andInvite.length > 0) {
await page
.getByRole("button", { name: "Invite to this room", exact: true })
.click();
const inviteInput = page.getByRole("dialog").getByRole("textbox");
for (const mxId of andInvite) {
await inviteInput.focus();
await inviteInput.fill(mxId);
await inviteInput.press("Enter");
}
await page.getByRole("button", { name: "Invite" }).click();
}
}
/**
* Sets the current Element Web app to use the dev Element Call URL.
* @param page - The EW page
*/
public static async setDevToolElementCallDevUrl(page: Page): Promise<void> {
if (process.env.USE_DOCKER) {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"http://localhost:8080/room",
);
});
} else {
await page.evaluate(() => {
window.mxSettingsStore.setValue(
"Developer.elementCallUrl",
null,
"device",
"https://localhost:3000/room",
);
});
}
}
}

View File

@@ -8,6 +8,7 @@ Please see LICENSE in the repository root for full details.
import { expect, test } from "@playwright/test";
import { widgetTest } from "../fixtures/widget-user.ts";
import { TestHelpers } from "./test-helpers.ts";
widgetTest.use({ callType: "dm" });
@@ -23,16 +24,7 @@ widgetTest(
const { brooks, whistler } = asWidget;
await expect(
brooks.page.getByRole("button", { name: "Voice call" }),
).toBeVisible();
await brooks.page.getByRole("button", { name: "Voice call" }).click();
await expect(
brooks.page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await brooks.page.getByRole("menuitem", { name: "Element Call" }).click();
await TestHelpers.startCallInCurrentRoom(brooks.page, true);
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
@@ -123,16 +115,7 @@ widgetTest(
const { brooks, whistler } = asWidget;
await expect(
brooks.page.getByRole("button", { name: "Video call" }),
).toBeVisible();
await brooks.page.getByRole("button", { name: "Video call" }).click();
await expect(
brooks.page.getByRole("menuitem", { name: "Element Call" }),
).toBeVisible();
await brooks.page.getByRole("menuitem", { name: "Element Call" }).click();
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
@@ -210,3 +193,48 @@ widgetTest(
await expect(brooks.page.locator(".mx_BasicMessageComposer")).toBeVisible();
},
);
widgetTest(
"Decline a new video call in DM as widget",
async ({ asWidget, browserName }) => {
test.skip(
browserName === "firefox",
"The is test is not working on firefox CI environment. No mic/audio device inputs so cam/mic are disabled",
);
test.slow(); // Triples the timeout
const { brooks, whistler } = asWidget;
await TestHelpers.startCallInCurrentRoom(brooks.page, false);
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
).toBeVisible();
const brooksFrame = brooks.page
.locator('iframe[title="Element Call"]')
.contentFrame();
// We should show a ringing overlay, let's check for that
await expect(
brooksFrame.getByText(`Waiting for ${whistler.displayName} to join…`),
).toBeVisible();
await expect(whistler.page.getByText("Incoming video call")).toBeVisible();
await whistler.page.getByRole("button", { name: "Decline" }).click();
await expect(
whistler.page.locator('iframe[title="Element Call"]'),
).not.toBeVisible();
// The widget should be closed and the timeline should be back on screen
await expect(
brooks.page.locator('iframe[title="Element Call"]'),
).not.toBeVisible();
await expect(
brooks.page.getByText("This is the beginning of your"),
).toBeVisible();
},
);

View File

@@ -842,6 +842,7 @@ export const InCallView: FC<InCallViewProps> = ({
.getConnections()
.map((connectionItem) => ({
room: connectionItem.livekitRoom,
livekitAlias: connectionItem.livekitAlias,
// TODO compute is local or tag it in the livekit room items already
isLocal: undefined,
url: connectionItem.transport.livekit_service_url,

View File

@@ -36,7 +36,7 @@ function createMockLivekitRoom(
wsUrl: string,
serverInfo: object,
metadata: string,
): { isLocal: boolean; url: string; room: LivekitRoom } {
): { isLocal: boolean; url: string; room: LivekitRoom; livekitAlias: string } {
const mockRoom = {
serverInfo,
metadata,
@@ -49,6 +49,7 @@ function createMockLivekitRoom(
isLocal: true,
url: wsUrl,
room: mockRoom,
livekitAlias: "TestAlias",
};
}
@@ -72,6 +73,7 @@ describe("DeveloperSettingsTab", () => {
room: LivekitRoom;
url: string;
isLocal?: boolean;
livekitAlias: string;
}[] = [
createMockLivekitRoom(
"wss://local-sfu.example.org",
@@ -80,6 +82,7 @@ describe("DeveloperSettingsTab", () => {
),
{
isLocal: false,
livekitAlias: "TestAlias2",
url: "wss://remote-sfu.example.org",
room: {
localParticipant: { identity: "localParticipantIdentity" },

View File

@@ -51,7 +51,12 @@ import { getSFUConfigWithOpenID } from "../livekit/openIDSFU";
interface Props {
client: MatrixClient;
roomId?: string;
livekitRooms?: { room: LivekitRoom; url: string; isLocal?: boolean }[];
livekitRooms?: {
room: LivekitRoom;
url: string;
isLocal?: boolean;
livekitAlias?: string;
}[];
env: ImportMetaEnv;
}
@@ -343,6 +348,7 @@ export const DeveloperSettingsTab: FC<Props> = ({
url: livekitRoom.url || "unknown",
})}
</h4>
<p>LivekitAlias: {livekitRoom.livekitAlias}</p>
{livekitRoom.isLocal && <p>ws-url: {localSfuUrl?.href}</p>}
<p>
{t("developer_mode.livekit_server_info")}(

View File

@@ -355,6 +355,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<h4>
LiveKit SFU: wss://local-sfu.example.org
</h4>
<p>
LivekitAlias:
TestAlias
</p>
<p>
ws-url:
wss://local-sfu.example.org/
@@ -393,6 +397,10 @@ exports[`DeveloperSettingsTab > renders and matches snapshot 1`] = `
<h4>
LiveKit SFU: wss://remote-sfu.example.org
</h4>
<p>
LivekitAlias:
TestAlias2
</p>
<p>
LiveKit Server Info
(

View File

@@ -124,9 +124,9 @@ import {
} from "./remoteMembers/ConnectionManager.ts";
import {
createMatrixLivekitMembers$,
type TaggedParticipant,
type LocalMatrixLivekitMember,
type RemoteMatrixLivekitMember,
type MatrixLivekitMember,
} from "./remoteMembers/MatrixLivekitMembers.ts";
import {
type AutoLeaveReason,
@@ -717,65 +717,38 @@ export function createCallViewModel$(
matrixLivekitMembers,
duplicateTiles,
]) {
let localUserMediaId: string | undefined = undefined;
// add local member if available
if (localMatrixLivekitMember) {
const computeMediaId = (m: MatrixLivekitMember): string =>
`${m.userId}:${m.membership$.value.deviceId}`;
const localUserMediaId = localMatrixLivekitMember
? computeMediaId(localMatrixLivekitMember)
: undefined;
const localAsArray = localMatrixLivekitMember
? [localMatrixLivekitMember]
: [];
const remoteWithoutLocal = matrixLivekitMembers.value.filter(
(m) => computeMediaId(m) !== localUserMediaId,
);
const allMatrixLivekitMembers = [
...localAsArray,
...remoteWithoutLocal,
];
for (const matrixLivekitMember of allMatrixLivekitMembers) {
const { userId, participant, connection$, membership$ } =
localMatrixLivekitMember;
localUserMediaId = `${userId}:${membership$.value.deviceId}`;
const rtcBackendIdentity = membership$.value.rtcBackendIdentity;
matrixLivekitMember;
const rtcId = membership$.value.rtcBackendIdentity; // rtcBackendIdentity
const mediaId = computeMediaId(matrixLivekitMember);
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield {
keys: [
dup,
localUserMediaId,
userId,
participant satisfies TaggedParticipant as TaggedParticipant, // Widen the type safely
connection$,
rtcBackendIdentity,
],
data: undefined,
};
}
}
// add remote members that are available
for (const {
userId,
participant,
connection$,
membership$,
} of matrixLivekitMembers.value) {
const userMediaId = `${userId}:${membership$.value.deviceId}`;
const rtcBackendIdentity = membership$.value.rtcBackendIdentity;
// skip local user as we added them manually before
if (userMediaId === localUserMediaId) continue;
for (let dup = 0; dup < 1 + duplicateTiles; dup++) {
yield {
keys: [
dup,
userMediaId,
userId,
participant,
connection$,
rtcBackendIdentity,
],
keys: [dup, mediaId, userId, participant, connection$, rtcId],
data: undefined,
};
}
}
},
(
scope,
_data$,
dup,
userMediaId,
userId,
participant,
connection$,
rtcBackendIdentity,
) => {
(scope, _, dup, mediaId, userId, participant, connection$, rtcId) => {
const livekitRoom$ = scope.behavior(
connection$.pipe(map((c) => c?.livekitRoom)),
);
@@ -790,9 +763,9 @@ export function createCallViewModel$(
return new UserMedia(
scope,
`${userMediaId}:${dup}`,
`${mediaId}:${dup}`,
userId,
rtcBackendIdentity,
rtcId,
participant,
options.encryptionSystem,
livekitRoom$,
@@ -801,8 +774,8 @@ export function createCallViewModel$(
localMembership.reconnecting$,
displayName$,
matrixMemberMetadataStore.createAvatarUrlBehavior$(userId),
handsRaised$.pipe(map((v) => v[userMediaId]?.time ?? null)),
reactions$.pipe(map((v) => v[userMediaId] ?? undefined)),
handsRaised$.pipe(map((v) => v[mediaId]?.time ?? null)),
reactions$.pipe(map((v) => v[mediaId] ?? undefined)),
);
},
),

View File

@@ -68,6 +68,27 @@ export enum JwtEndpointVersion {
Matrix_2_0 = "matrix_2_0",
}
// TODO livekit_alias-cleanup
// 1. We need to move away from transports map to connections!!!
//
// 2. We need to stop sending livekit_alias all together
//
//
// 1.
// Transports are just the jwt service adress but do not contain the information which room on this transport to use.
// That requires slot and roomId.
//
// We need one connection per room on the transport.
//
// We need an object that contains:
// transport
// roomId
// slotId
//
// To map to the connections. Prosposal: `ConnectionIdentifier`
//
// 2.
// We need to make sure we do not sent livekit_alias in sticky events and that we drop all code for sending state events!
export interface LocalTransportWithSFUConfig {
transport: LivekitTransport;
sfuConfig: SFUConfig;
@@ -250,7 +271,18 @@ async function makeTransport(
transport: {
type: "livekit",
livekit_service_url: url,
livekit_alias: sfuConfig.livekitAlias,
// WARNING PLS READ ME!!!
// This looks unintuitive especially considering that `sfuConfig.livekitAlias` exists.
// Why do we not use: `livekit_alias: sfuConfig.livekitAlias`
//
// - This is going to be used for sending our state event transport (focus_preferred)
// - In sticky events it is expected to NOT send this field at all. The transport is only the `type`, `livekit_service_url`
// - If we set it to the hased alias we get from the jwt, we will end up using the hashed alias as the body.roomId field
// in v0.16.0. (It will use oldest member transport. It is using the transport.livekit_alias as the body.roomId)
//
// TLDR this is a temporal field that allow for comaptibilty but the spec expects it to not exists. (but its existance also does not break anything)
// It is just named poorly: It was intetended to be the actual alias. But now we do pseudonymys ids so we use a hashed alias.
livekit_alias: roomId,
},
sfuConfig,
};

View File

@@ -117,6 +117,14 @@ export class Connection {
*/
public readonly remoteParticipants$: Behavior<RemoteParticipant[]>;
/**
* The alias of the LiveKit room.
*/
public get livekitAlias(): string | undefined {
return this._livekitAlias;
}
private _livekitAlias?: string;
/**
* Whether the connection has been stopped.
* @see Connection.stop
@@ -144,9 +152,10 @@ export class Connection {
this._state$.next(ConnectionState.FetchingConfig);
// We should already have this information after creating the localTransport.
// only call getSFUConfigWithOpenID for connections where we do not have a token yet. (existingJwtTokenData === undefined)
const { url, jwt } =
const { url, jwt, livekitAlias } =
this.existingSFUConfig ??
(await this.getSFUConfigForRemoteConnection());
this._livekitAlias = livekitAlias;
// If we were stopped while fetching the config, don't proceed to connect
if (this.stopped) return;

View File

@@ -33,7 +33,7 @@ export type TaggedParticipant =
| LocalTaggedParticipant
| RemoteTaggedParticipant;
interface MatrixLivekitMember {
export interface MatrixLivekitMember {
membership$: Behavior<CallMembership>;
connection$: Behavior<Connection | null>;
// participantId: string; We do not want a participantId here since it will be generated by the jwt
@@ -143,9 +143,14 @@ export function areLivekitTransportsEqual<T extends LivekitTransport>(
t1: T | null,
t2: T | null,
): boolean {
if (t1 && t2) return t1.livekit_service_url === t2.livekit_service_url;
// In case we have different lk rooms in the same SFU (depends on the livekit authorization service)
// It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation)
if (t1 && t2)
return (
t1.livekit_service_url === t2.livekit_service_url &&
// In case we have different lk rooms in the same SFU (depends on the livekit authorization service)
// It is only needed in case the livekit authorization service is not behaving as expected (or custom implementation)
// Also LivekitTransport is planned to become a `ConnectionIdentifier` which moves this equal somewhere else.
t1.livekit_alias === t2.livekit_alias
);
if (!t1 && !t2) return true;
return false;
}

View File

@@ -2903,16 +2903,6 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/dom@npm:1.6.13, @floating-ui/dom@npm:^1.0.0":
version: 1.6.13
resolution: "@floating-ui/dom@npm:1.6.13"
dependencies:
"@floating-ui/core": "npm:^1.6.0"
"@floating-ui/utils": "npm:^0.2.9"
checksum: 10c0/272242d2eb6238ffcee0cb1f3c66e0eafae804d5d7b449db5ecf904bc37d31ad96cf575a9e650b93c1190f64f49a684b1559d10e05ed3ec210628b19116991a9
languageName: node
linkType: hard
"@floating-ui/dom@npm:1.7.4":
version: 1.7.4
resolution: "@floating-ui/dom@npm:1.7.4"
@@ -2923,6 +2913,16 @@ __metadata:
languageName: node
linkType: hard
"@floating-ui/dom@npm:^1.0.0":
version: 1.6.13
resolution: "@floating-ui/dom@npm:1.6.13"
dependencies:
"@floating-ui/core": "npm:^1.6.0"
"@floating-ui/utils": "npm:^0.2.9"
checksum: 10c0/272242d2eb6238ffcee0cb1f3c66e0eafae804d5d7b449db5ecf904bc37d31ad96cf575a9e650b93c1190f64f49a684b1559d10e05ed3ec210628b19116991a9
languageName: node
linkType: hard
"@floating-ui/react-dom@npm:^2.0.0, @floating-ui/react-dom@npm:^2.1.2":
version: 2.1.2
resolution: "@floating-ui/react-dom@npm:2.1.2"
@@ -3213,21 +3213,7 @@ __metadata:
languageName: node
linkType: hard
"@livekit/components-core@npm:0.12.11":
version: 0.12.11
resolution: "@livekit/components-core@npm:0.12.11"
dependencies:
"@floating-ui/dom": "npm:1.6.13"
loglevel: "npm:1.9.1"
rxjs: "npm:7.8.2"
peerDependencies:
livekit-client: ^2.15.14
tslib: ^2.6.2
checksum: 10c0/9c2ac3d30bb8cc9067ae0b2049784f81e90e57df9eabf7edbaf3c8ceb65a63f644a4e6abeb6cc38d3ebe52663d8dbb88535e01a965011f365d5ae1f3daf86052
languageName: node
linkType: hard
"@livekit/components-core@npm:^0.12.0":
"@livekit/components-core@npm:0.12.12, @livekit/components-core@npm:^0.12.0":
version: 0.12.12
resolution: "@livekit/components-core@npm:0.12.12"
dependencies:
@@ -3242,10 +3228,10 @@ __metadata:
linkType: hard
"@livekit/components-react@npm:^2.0.0":
version: 2.9.16
resolution: "@livekit/components-react@npm:2.9.16"
version: 2.9.17
resolution: "@livekit/components-react@npm:2.9.17"
dependencies:
"@livekit/components-core": "npm:0.12.11"
"@livekit/components-core": "npm:0.12.12"
clsx: "npm:2.1.1"
events: "npm:^3.3.0"
jose: "npm:^6.0.12"
@@ -3259,7 +3245,7 @@ __metadata:
peerDependenciesMeta:
"@livekit/krisp-noise-filter":
optional: true
checksum: 10c0/4ba4ff473c5a29d3107412733a6676a3b708d70684ed463e9b34cda26abb3d2f317c2828a52e730837b756de9df3fc248260d6f390aedebfb6ec96ef63c7b151
checksum: 10c0/ba64ada37d4b3ce4d5ee7c5b2a6bddbffc17c2e641e95881aac9f02b4ff7428105e0a372d364e50ff124e988b7426d322d94caabdb55b634aebf0144d7e37f99
languageName: node
linkType: hard