diff --git a/.changeset/moody-snails-jog.md b/.changeset/moody-snails-jog.md new file mode 100644 index 000000000..8c95c2bf3 --- /dev/null +++ b/.changeset/moody-snails-jog.md @@ -0,0 +1,6 @@ +--- +'@nordeck/matrix-neoboard-widget': minor +'@nordeck/matrix-neoboard-react-sdk': minor +--- + +Update MatrixRTC implementation diff --git a/docs/model/matrix-rtc-events.md b/docs/model/matrix-rtc-events.md index 6cb216473..3cb008252 100644 --- a/docs/model/matrix-rtc-events.md +++ b/docs/model/matrix-rtc-events.md @@ -1,98 +1,170 @@ # MatrixRTC Events Data Model Having the option to use MatrixRTC as an alternative to WebRTC introduces some -changes to the data model. +changes: -Specifically, we replace the `net.nordeck.whiteboard.sessions`events with the -`m.rtc.member` MatrixRTC membership state events (or the unstable `org.matrix.msc3401.call.member`) -and no longer need to use To Device Messages for establishing peer connections, -as all signaling and connection logic is now handled by the LiveKit Client SDK -and LiveKit Server backend. +We add the `m.rtc.slot` state event (or the unstable `org.matrix.msc4143.rtc.slot`). +It is defined in MatrixRTC as applicaiton virtual location. -## Room Messages +We replace the `net.nordeck.whiteboard.sessions` state event with the +MatrixRTC `m.rtc.member` membership **sticky** event (or the unstable `org.matrix.msc4143.rtc.member`). -The whiteboard state and RTC session membership is stored using the following events in a Matrix room: +We no longer need to use To Device Messages for establishing peer connections. +All signaling and connection logic is now handled by the LiveKit Client SDK and LiveKit Server backend. + +## Room Events + +The whiteboard RTC state and membership is stored using the following events in a Matrix room: ``` -┌────────────────────────────────┐ ┌────────────────────────────────────┐ -│ │ │ │ -│ net.nordeck.whiteboard │◄─────────────────┤ org.matrix.msc3401.call.member │ -│ (state_key: ) │ (whiteboard-id) │ (state_key: __)│ -│ │ │ │ -└──┬─────────────────────────────┘ └────────────────────────────────────┘ - │ - │ content.documentId - │ - ▼ -┌────────────────────────────────────────┐ -│ │ ◄─────── -│ net.nordeck.whiteboard.document.create │ -│ event_id ≙ documentId │ ◄─────── -│ │ -└────────────────────────────────────────┘ ... +┌───────────────────────────────────────────────────┐ +│ │ +│ org.matrix.msc4143.rtc.slot │ ┌───────────────────────────────┐ +│ state_key: net.nordeck.whiteboard# │ content.slot_id │ │ +│ content.status: open │◄───────────────────┤ org.matrix.msc4143.rtc.member │ +│ content.application.type: net.nordeck.whiteboard │ │ │ +│ │ └───────────────────────────────┘ +└───────────────────────────────────────────────────┘ ``` All other events and relations remain as described in [Matrix Events](matrix-events.md) -### `org.matrix.msc3401.call.member` (State Event) +### `org.matrix.msc4143.rtc.slot` (State event) -According to [MSC4143: MatrixRTC][MSC4143], a RTC application must keep it's session state in -a state event of type `m.rtc.member` (or the unstable `org.matrix.msc3401.call.member`), -with a state key composed of the user's Matrix ID and the user's Device ID. +This is a new [MSC4143: MatrixRTC][MSC4143] state event with the fields: `status`, `applicaiton.type`. -This state event will keep RTC and app-specific medatada in it's `content` field. +It holds a slot ID as a state key that is specific to Nordeck whiteboard +and has the form: `net.nordeck.whiteboard#`. -The termination of a RTC session is signaled by clearing the state event's `content`. This is done using delayed events. +#### Fields -#### Content +| Field | Type | Description | +| ------------------------ | ------------------------ | -------------------------------------------------------- | +| `status` | `'open'` | A slot's status. | +| `application.type` | `net.nordeck.whiteboard` | Nordeck whiteboard application identifier. | +| `application.documentId` | `string` | A `net.nordeck.whiteboard.document.create` room event id | -| Field | Type | Description | -| -------------------------------------- | -------- | -------------------------------------------------------------------------- | -| `application` | `string` | The NeoBoard application identifier, which is `net.nordeck.whiteboard`. | -| `call_id` | `string` | The ID of the Whiteboard for this session, which matches the Widget ID. | -| `device_id` | `string` | The Device ID of the user's client. | -| `focus_active` | `object` | The currently active backend focus type and focus selection strategy. | -| `focus_active.type` | `string` | The type of the focus, `livekit` for LiveKit. | -| `focus_active.focus_selection` | `string` | The focus selection strategy. Currently only supports `oldest_membership`. | -| `foci_preferred[]` | `array` | A list of possible foci this user knows about. | -| `foci_preferred[].type` | `string` | The type of the focus, `livekit` for LiveKit. | -| `foci_preferred[].livekit_service_url` | `string` | The URL of the LiveKit MatrixRTC backend to use for the session. | -| `scope` | `string` | The scope of the RTC session. Only supported value is 'm.room'. | -| `expires` | `number` | The expiration timestamp for this session membership. | +#### Example + +```json +{ + "type": "org.matrix.msc4143.rtc.slot", + "sender": "@user-id:example.com", + "state_key": "net.nordeck.whiteboard#", + "content": { + "status": "open", + "application": { + "type": "net.nordeck.whiteboard", + "documentId": "$H1-nssrxUGbrMdKSDJcACCpmc4PrClb2WDSOrGUv6bs" + } + }, + "event_id": "$event-id", + "origin_server_ts": 1665134498391, + "room_id": "!room-id:example.com" +} +``` + +### `org.matrix.msc4143.rtc.member` (Sticky event) with `join` membership + +According to [MSC4143: MatrixRTC][MSC4143], a RTC application must keep it's membership data in +the sticky event of type `m.rtc.member` with `member.membership` to be `join`. + +Sending a `m.rtc.member` event with `membership` equal to `join` for an opened slot opens a session. + +#### Fields + +| Field | Type | Description | +| --------------------------- | -------------------------- | ------------------------------------------------------------------------------- | +| `slot_id` | `string` | A MatrixRTC slot ID, example: `net.nordeck.whiteboard#whiteboard-id` | +| `member.id` | `string` | A unique user identifier for each join, even for the same user and device. | +| `member.membership` | `'join'` | Identifies membership event as `join`. | +| `member.deviceId` | `string` | The Device ID of the user's client. | +| `application.type` | `'net.nordeck.whiteboard'` | Nordeck whiteboard application identifier. | +| `application.whiteboard_id` | `string` | A whiteboard id. | +| `transports.published[]` | `array` | Array of transports used by member to publish media, see MatrixRTC transports. | +| `transports.can_subscribe` | `array` | Array of transports member can subscribe to. At the moment: `['livekit']` only. | +| `msc4354_sticky_key` | `string` | The sticky key. Must be the same as `member.id`. | #### Example ```json { - "type": "org.matrix.msc3401.call.member", - "sender": "@alice:matrix.internal", + "type": "org.matrix.msc4143.rtc.member", + "sender": "@user-id:example.com", "content": { - "application": "net.nordeck.whiteboard", - "call_id": "whiteboard-id", - "device_id": "SDXDZRNDJA", - "focus_active": { - "type": "livekit", - "focus_selection": "oldest_membership" + "slot_id": "net.nordeck.whiteboard#whiteboard-id", + "member": { + "id": "$member-id-0", + "membership": "join", + "device_id": "$device-id-0" + }, + "application": { + "type": "net.nordeck.whiteboard", + "whiteboard_id": "whiteboard-id" + }, + "transports": { + "published": [ + { + "type": "livekit", + "livekit_service_url": "https://livekit-jwt.example.com" + } + ], + "can_subscribe": ["livekit"] }, - "foci_preferred": [ - { - "type": "livekit", - "livekit_service_url": "https//livekit-jwt.matrix.internal" - } - ], - "scope": "m.room", - "expires": 1743778636001 + "msc4354_sticky_key": "$member-id-0" }, - "state_key": "_@alice:matrix.internal_SDXDZRNDJA", - "origin_server_ts": 1743764236021, - "unsigned": { - "membership": "join", - "age": 68 + "origin_server_ts": 0, + "event_id": "$event-id", + "room_id": "!room-id:example.com" +} +``` + +### `org.matrix.msc4143.rtc.member` (Sticky event) with `leave` membership + +A session is terminated when a `m.rtc.member` event with a `leave` `membership` is sent. + +The `leave_reason.code` is set to `leave` when users leaves intentionally. + +The `leave_reason.code` is set to `delayed_leave` when user's [MSC4140 delayed event][MSC4140] to leave is sent. + +#### Fields + +| Field | Type | Description | +| -------------------- | ---------------------------- | -------------------------------------------------------------------------- | +| `slot_id` | `string` | A MatrixRTC slot ID, example: `net.nordeck.whiteboard#whiteboard-id` | +| `member.id` | `string` | A unique user identifier for each join, even for the same user and device. | +| `member.membership` | `'leave'` | Identifies membership event as `leave`. | +| `member.deviceId` | `string` | The Device ID of the user's client. | +| `leave_reason.code` | `'leave' \| 'delayed_leave'` | Leave code. | +| `msc4354_sticky_key` | `string` | The sticky key. Must be the same as `member.id`. | + +#### Example + +```json +{ + "type": "org.matrix.msc4143.rtc.member", + "sender": "@user-id:example.com", + "content": { + "slot_id": "net.nordeck.whiteboard#whiteboard-id", + "member": { + "id": "$member-id-0", + "membership": "leave", + "device_id": "$device-id-0" + }, + "leave_reason": { + "code": "leave" + }, + "msc4354_sticky_key": "$member-id-0" }, - "event_id": "$bFsA4Obl-sneiJlq4SAM2WGMLe00ie3f-Mod7VQfF_c", - "room_id": "!BWCjlIjHYWgJyZySxE:matrix.internal" + "origin_server_ts": 0, + "event_id": "$event-id", + "room_id": "!room-id:example.com", + "msc4354_sticky": { + "duration_ms": 3600000 + } } ``` [matrix-events]: ./matrix-events.md [MSC4143]: https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixRTC/proposals/4143-matrix-rtc.md +[MSC4140]: https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/expiring-events-keep-alive/proposals/4140-delayed-events-futures.md diff --git a/matrix-neoboard-widget/package.json b/matrix-neoboard-widget/package.json index 44b1fb3dd..5d3dc964a 100644 --- a/matrix-neoboard-widget/package.json +++ b/matrix-neoboard-widget/package.json @@ -7,8 +7,8 @@ "private": true, "type": "module", "dependencies": { - "@matrix-widget-toolkit/api": "^5.0.3", - "@matrix-widget-toolkit/mui": "^2.2.1", + "@matrix-widget-toolkit/api": "^5.1.0", + "@matrix-widget-toolkit/mui": "^2.2.2", "@nordeck/matrix-neoboard-react-sdk": "1.4.0", "i18next": "^25.10.10", "i18next-browser-languagedetector": "^8.2.1", @@ -16,7 +16,7 @@ "i18next-http-backend": "^3.0.6", "loglevel": "^1.9.2", "loglevel-plugin-prefix": "^0.8.4", - "matrix-widget-api": "^1.16.1", + "matrix-widget-api": "1.18.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-i18next": "^15.7.4", @@ -52,7 +52,7 @@ "docker:build": "docker build --build-context root=../ -t nordeck/matrix-neoboard-widget .", "docker:run": "dotenv -c -- docker run -it --rm -e REACT_APP_HOME_SERVER_URL -p 3000:8080 --name matrix-neoboard-widget nordeck/matrix-neoboard-widget", "dev": "vite", - "dev:https": "VITE_DEV_SSL=true vite", + "dev:https": "VITE_DEV_SSL=true vite --force", "preview": "vite preview", "preview:https": "VITE_DEV_SSL=true vite preview", "build": "tsc && vite build", diff --git a/matrix-neoboard-widget/src/index.tsx b/matrix-neoboard-widget/src/index.tsx index 607105eb7..f323b0c48 100644 --- a/matrix-neoboard-widget/src/index.tsx +++ b/matrix-neoboard-widget/src/index.tsx @@ -53,7 +53,11 @@ const widgetApiPromise = WidgetApiImpl.create({ }); const store = createStore({ widgetApi: widgetApiPromise }); -const whiteboardManager = createWhiteboardManager(store, widgetApiPromise); +const whiteboardManager = createWhiteboardManager( + store, + widgetApiPromise, + false, +); const container = document.getElementById('root'); const root = createRoot(container!); diff --git a/matrix-neoboard-widget/src/widgetCapabilities.ts b/matrix-neoboard-widget/src/widgetCapabilities.ts index 53dbded73..797e74479 100644 --- a/matrix-neoboard-widget/src/widgetCapabilities.ts +++ b/matrix-neoboard-widget/src/widgetCapabilities.ts @@ -21,16 +21,17 @@ import { STATE_EVENT_ROOM_MEMBER, } from '@matrix-widget-toolkit/api'; import { + isMatrixRtcMode, + ROOM_EVENT_4143_RTC_MEMBER, ROOM_EVENT_DOCUMENT_CHUNK, ROOM_EVENT_DOCUMENT_CREATE, ROOM_EVENT_DOCUMENT_SNAPSHOT, + STATE_EVENT_4143_RTC_SLOT, STATE_EVENT_ROOM_NAME, - STATE_EVENT_RTC_MEMBER, STATE_EVENT_WHITEBOARD, STATE_EVENT_WHITEBOARD_SESSIONS, TO_DEVICE_MESSAGE_CONNECTION_SIGNALING, } from '@nordeck/matrix-neoboard-react-sdk'; -import { matrixRtcMode } from '@nordeck/matrix-neoboard-react-sdk/src/components/Whiteboard'; import { EventDirection, MatrixCapabilities, @@ -38,7 +39,7 @@ import { WidgetEventCapability, } from 'matrix-widget-api'; -const { userId, deviceId } = extractWidgetParameters(); +const { userId } = extractWidgetParameters(); export const widgetCapabilities = [ WidgetEventCapability.forRoomEvent( @@ -112,18 +113,23 @@ export const widgetCapabilities = [ WidgetApiFromWidgetAction.MSC4039DownloadFileAction, ]; -if (matrixRtcMode) { +if (isMatrixRtcMode()) { widgetCapabilities.push( + MatrixCapabilities.MSC4515RtcTransports, WidgetEventCapability.forStateEvent( EventDirection.Send, - STATE_EVENT_RTC_MEMBER, - // We only need to write the own state, but read state from everyone - `_${userId}_${deviceId}`, + STATE_EVENT_4143_RTC_SLOT, ), - WidgetEventCapability.forStateEvent( + WidgetEventCapability.forRoomEvent( + EventDirection.Send, + ROOM_EVENT_4143_RTC_MEMBER, + ), + WidgetEventCapability.forRoomEvent( EventDirection.Receive, - STATE_EVENT_RTC_MEMBER, + ROOM_EVENT_4143_RTC_MEMBER, ), + MatrixCapabilities.MSC4407ReceiveStickyEvent, + MatrixCapabilities.MSC4407SendStickyEvent, MatrixCapabilities.MSC4157SendDelayedEvent, MatrixCapabilities.MSC4157UpdateDelayedEvent, ); diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index dd7aae8a5..230062303 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -15,9 +15,9 @@ "type": "module", "dependencies": { "@fontsource/noto-emoji": "^5.2.11", - "@matrix-widget-toolkit/api": "5.0.3", - "@matrix-widget-toolkit/mui": "2.2.1", - "@matrix-widget-toolkit/react": "2.1.1", + "@matrix-widget-toolkit/api": "5.1.0", + "@matrix-widget-toolkit/mui": "2.2.2", + "@matrix-widget-toolkit/react": "2.1.2", "@mui/base": "^5.0.0-beta.70", "@mui/icons-material": "^6.5.0", "@mui/lab": "^6.0.1-beta.36", @@ -27,11 +27,11 @@ "emoji-regex": "^10.6.0", "joi": "^17.13.4", "js-base64": "^3.7.8", - "livekit-client": "^2.18.10", + "livekit-client": "^2.21.0", "localforage": "^1.10.0", "lodash": "^4.18.1", "loglevel": "^1.9.2", - "matrix-widget-api": "^1.16.1", + "matrix-widget-api": "1.18.0", "pdfmake": "^0.2.23", "react-beautiful-dnd": "^13.1.1", "react-draggable": "^4.4.6", @@ -52,7 +52,7 @@ "react-i18next": "^15.5.2" }, "devDependencies": { - "@matrix-widget-toolkit/testing": "^4.0.1", + "@matrix-widget-toolkit/testing": "^4.1.0", "@testing-library/dom": "10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.2.0", diff --git a/packages/react-sdk/src/components/CollaborationBar/Collaborators/Collaborators.test.tsx b/packages/react-sdk/src/components/CollaborationBar/Collaborators/Collaborators.test.tsx index cbb00422a..14944fac0 100644 --- a/packages/react-sdk/src/components/CollaborationBar/Collaborators/Collaborators.test.tsx +++ b/packages/react-sdk/src/components/CollaborationBar/Collaborators/Collaborators.test.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { getEnvironment } from '@matrix-widget-toolkit/mui'; import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; import { act, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -36,14 +37,24 @@ import { import { mockRoomMember } from '../../../lib/testUtils/matrixTestUtils'; import { WhiteboardManager, WhiteboardStatistics } from '../../../state'; import { Toolbar } from '../../common/Toolbar'; -import * as constants from '../../Whiteboard/constants'; import { Collaborators } from './Collaborators'; +vi.mock('@matrix-widget-toolkit/mui', async () => ({ + ...(await vi.importActual( + '@matrix-widget-toolkit/mui', + )), + getEnvironment: vi.fn(), +})); + let widgetApi: MockedWidgetApi; afterEach(() => widgetApi.stop()); beforeEach(() => { + vi.mocked(getEnvironment).mockImplementation( + (_, defaultValue) => defaultValue, + ); + widgetApi = mockWidgetApi(); }); @@ -110,7 +121,9 @@ describe('', () => { statistics = { communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-bob:example.com', @@ -141,50 +154,29 @@ describe('', () => { 'connected', ), }, - sessions: [ - { + sessions: { + 'session-0': { userId: '@user-bob:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-0', - whiteboardId: 'whiteboard-id', }, - { + 'session-1': { userId: '@user-charlie:example.com', - expiresTs: Date.now() - 1000, - sessionId: 'session-1', - whiteboardId: 'whiteboard-id', }, - { + 'session-2': { userId: '@user-dave:example.com', - expiresTs: Date.now() - 1000, - sessionId: 'session-2', - whiteboardId: 'whiteboard-id', }, - { + 'session-3': { userId: '@user-erin:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-3', - whiteboardId: 'whiteboard-id', }, - { + 'session-4': { userId: '@user-frank:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-4', - whiteboardId: 'whiteboard-id', }, - { + 'session-5': { userId: '@user-grace:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-5', - whiteboardId: 'whiteboard-id', }, - { + 'session-6': { userId: '@user-heidi:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-6', - whiteboardId: 'whiteboard-id', }, - ], + }, }, document: { contentSizeInBytes: 0, @@ -380,7 +372,9 @@ describe('', () => { describe('MatrixRTC mode', () => { beforeEach(() => { - vi.spyOn(constants, 'matrixRtcMode', 'get').mockReturnValue(true); + vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => + name === 'REACT_APP_RTC' ? 'matrixrtc' : defaultValue, + ); }); afterEach(() => { @@ -388,32 +382,22 @@ describe('', () => { }); it('should show active users', async () => { - statistics.communicationChannel.sessions = [ - { + statistics.communicationChannel.peerConnections = { + 'https://livekit-jwt.example.com': mockPeerConnectionStatistics( + '@user:example.com', + 'connected', + 'other', + ['session-0', 'session-3'], + ), + }; + statistics.communicationChannel.sessions = { + 'session-0': { userId: '@user-bob:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-0', - whiteboardId: 'whiteboard-id', - }, - { - userId: '@user-charlie:example.com', - expiresTs: Date.now() - 1000, - sessionId: 'session-1', - whiteboardId: 'whiteboard-id', - }, - { - userId: '@user-dave:example.com', - expiresTs: Date.now() - 1000, - sessionId: 'session-2', - whiteboardId: 'whiteboard-id', }, - { + 'session-3': { userId: '@user-erin:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-3', - whiteboardId: 'whiteboard-id', }, - ]; + }; render(, { wrapper: Wrapper }); @@ -429,6 +413,14 @@ describe('', () => { }); it('should show more button for more than six users', async () => { + statistics.communicationChannel.peerConnections = { + 'https://livekit-jwt.example.com': mockPeerConnectionStatistics( + '@user:example.com', + 'connected', + 'other', + ['session-0', 'session-3', 'session-4', 'session-5', 'session-6'], + ), + }; render(, { wrapper: Wrapper }); const group = screen.getByRole('group', { name: 'Collaborators' }); @@ -456,50 +448,41 @@ describe('', () => { }); it('should show more button for more users', async () => { - statistics.communicationChannel.sessions = [ - { + statistics.communicationChannel.peerConnections = { + 'https://livekit-jwt.example.com': mockPeerConnectionStatistics( + '@user:example.com', + 'connected', + 'other', + [ + 'session-0', + 'session-2', + 'session-3', + 'session-4', + 'session-5', + 'session-6', + ], + ), + }; + statistics.communicationChannel.sessions = { + 'session-0': { userId: '@user-bob:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-0', - whiteboardId: 'whiteboard-id', }, - { - userId: '@user-charlie:example.com', - expiresTs: Date.now() - 1000, - sessionId: 'session-1', - whiteboardId: 'whiteboard-id', - }, - { + 'session-2': { userId: '@user-dave:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-2', - whiteboardId: 'whiteboard-id', }, - { + 'session-3': { userId: '@user-erin:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-3', - whiteboardId: 'whiteboard-id', }, - { + 'session-4': { userId: '@user-frank:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-4', - whiteboardId: 'whiteboard-id', }, - { + 'session-5': { userId: '@user-grace:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-5', - whiteboardId: 'whiteboard-id', }, - { + 'session-6': { userId: '@user-heidi:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-6', - whiteboardId: 'whiteboard-id', }, - ]; + }; render(, { wrapper: Wrapper }); diff --git a/packages/react-sdk/src/components/DeveloperTools/CommunicationChannelStatisticsView.tsx b/packages/react-sdk/src/components/DeveloperTools/CommunicationChannelStatisticsView.tsx index c30a47d83..e3cdcc49d 100644 --- a/packages/react-sdk/src/components/DeveloperTools/CommunicationChannelStatisticsView.tsx +++ b/packages/react-sdk/src/components/DeveloperTools/CommunicationChannelStatisticsView.tsx @@ -14,12 +14,12 @@ * limitations under the License. */ -import { TableBody, TableHead, TableRow } from '@mui/material'; +import { TableBody, TableRow } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { isMatrixRtcMode } from '../../lib'; import { CommunicationChannelStatistics } from '../../state/communication'; import { PeerConnectionDetail } from './PeerConnectionDetail'; import { - StyledDevtoolsHeaderCell, StyledDevtoolsTable, StyledDevtoolsTableCell, } from './StyledDevtoolsTable'; @@ -30,6 +30,7 @@ export function CommunicationChannelStatisticsView({ communicationChannel: CommunicationChannelStatistics; }) { const { t } = useTranslation('neoboard'); + const matrixRtcMode = isMatrixRtcMode(); return ( - + - + {t( 'boardBar.developerToolsDialog.communicationChannelStatistics.localSessionId', 'Local Session Id', )} - /> - + + {communicationChannel.localSession?.sessionId} + + + {matrixRtcMode && ( + + + {t( + 'boardBar.developerToolsDialog.communicationChannelStatistics.localMemberId', + 'Local Member Id', + )} + + + {communicationChannel.localSession?.memberId} + + + )} + {matrixRtcMode && ( + + + {t( + 'boardBar.developerToolsDialog.communicationChannelStatistics.localSfuConnectionId', + 'Local SFU Connection Id', + )} + + + {communicationChannel.localSession?.livekitServiceUrl} + + + )} + + + {t( 'boardBar.developerToolsDialog.communicationChannelStatistics.peers', 'Peers', )} - /> - - - - - - {communicationChannel.localSessionId} - + {Object.keys(communicationChannel.peerConnections).length} diff --git a/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.test.tsx b/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.test.tsx index d78a1dfb9..5c2238f28 100644 --- a/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.test.tsx +++ b/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.test.tsx @@ -81,7 +81,7 @@ describe('', () => { screen.getByText('Document Sync Statistics (Snapshots)'), ).toBeInTheDocument(); expect( - screen.getByText('Communication Channel Statistics (WRTC)'), + screen.getByText('Communication Channel Statistics'), ).toBeInTheDocument(); expect(screen.getByText('Sessions')).toBeInTheDocument(); }); @@ -134,13 +134,13 @@ describe('', () => { }); }); - it('should open and close the "Communication Channel Statistics (WRTC)" accordion when clicked', async () => { + it('should open and close the "Communication Channel Statistics" accordion when clicked', async () => { render(, { wrapper: Wrapper, }); const communicationChannelAccordion = screen.getByRole('button', { - name: 'Communication Channel Statistics (WRTC)', + name: 'Communication Channel Statistics', }); await userEvent.click(communicationChannelAccordion); diff --git a/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.tsx b/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.tsx index 5edfb1341..ff0ff9636 100644 --- a/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.tsx +++ b/packages/react-sdk/src/components/DeveloperTools/DeveloperToolsDialog.tsx @@ -111,7 +111,7 @@ export function DeveloperToolsDialog({ {t( 'boardBar.developerToolsDialog.communicationChannelStatisticsTitle', - 'Communication Channel Statistics (WRTC)', + 'Communication Channel Statistics', )} @@ -132,12 +132,11 @@ export function DeveloperToolsDialog({ + + {communicationChannel.localSession?.sessionId} + - s.sessionId !== communicationChannel.localSessionId && - s.expiresTs - Date.now() > 0, - )} + sessions={Object.entries(communicationChannel.sessions)} /> diff --git a/packages/react-sdk/src/components/DeveloperTools/PeerConnectionDetail.tsx b/packages/react-sdk/src/components/DeveloperTools/PeerConnectionDetail.tsx index 05f7d7bbd..0777cba5c 100644 --- a/packages/react-sdk/src/components/DeveloperTools/PeerConnectionDetail.tsx +++ b/packages/react-sdk/src/components/DeveloperTools/PeerConnectionDetail.tsx @@ -16,8 +16,8 @@ import { Box, Table, TableBody, TableRow, Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; +import { isMatrixRtcMode } from '../../lib'; import { PeerConnectionStatistics } from '../../state/communication'; -import { matrixRtcMode } from '../Whiteboard'; import { StyledDevtoolsTableCell } from './StyledDevtoolsTable'; export function PeerConnectionDetail({ @@ -28,36 +28,67 @@ export function PeerConnectionDetail({ peerConnection: PeerConnectionStatistics; }) { const { t } = useTranslation('neoboard'); + const matrixRtcMode = isMatrixRtcMode(); return ( <> - {`${peerConnection.remoteUserId}: ${connectionId}`} + {connectionId} - - - {t( - 'boardBar.developerToolsDialog.communicationChannelStatistics.remoteSessionId', - 'Remote Session Id', - )} - - - {peerConnection.remoteSessionId} - - - - - {t( - 'boardBar.developerToolsDialog.communicationChannelStatistics.userId', - 'User Id', - )} - - - {peerConnection.remoteUserId} - - + {!matrixRtcMode && ( + + + {t( + 'boardBar.developerToolsDialog.communicationChannelStatistics.remoteSessionId', + 'Remote Session Id', + )} + + + {peerConnection.remoteSessionId} + + + )} + {!matrixRtcMode && ( + + + {t( + 'boardBar.developerToolsDialog.communicationChannelStatistics.remoteUserId', + 'Remote User Id', + )} + + + {peerConnection.remoteUserId} + + + )} + {matrixRtcMode && ( + + + {t( + 'boardBar.developerToolsDialog.communicationChannelStatistics.localParticipantIdentity', + 'Local Participant Identity', + )} + + + {peerConnection.localParticipantIdentity} + + + )} + {matrixRtcMode && ( + + + {t( + 'boardBar.developerToolsDialog.communicationChannelStatistics.remoteParticipantIdentities', + 'Remote Participant Identities', + )} + + + {peerConnection.remoteParticipantIdentities?.join(', ')} + + + )} {!matrixRtcMode && ( diff --git a/packages/react-sdk/src/components/DeveloperTools/WhiteboardSessions.tsx b/packages/react-sdk/src/components/DeveloperTools/WhiteboardSessions.tsx index 808855688..757d25fdf 100644 --- a/packages/react-sdk/src/components/DeveloperTools/WhiteboardSessions.tsx +++ b/packages/react-sdk/src/components/DeveloperTools/WhiteboardSessions.tsx @@ -16,7 +16,7 @@ import { TableBody, TableHead, TableRow } from '@mui/material'; import { useTranslation } from 'react-i18next'; -import { SessionState } from '../../state/communication/discovery/sessionManagerImpl'; +import { SessionStatistics } from '../../state/communication'; import { StyledDevtoolsHeaderCell, StyledDevtoolsTable, @@ -26,7 +26,7 @@ import { export function WhiteboardSessionsTable({ sessions, }: { - sessions: SessionState[] | undefined; + sessions: [string, SessionStatistics][]; }) { const { t } = useTranslation('neoboard'); @@ -51,32 +51,21 @@ export function WhiteboardSessionsTable({ 'Session ID', )} /> - - {sessions && sessions.length > 0 ? ( + {sessions.length > 0 ? ( sessions - .sort((a, b) => a.userId.localeCompare(b.userId)) - .map((session) => ( - + .sort(([_sessionIdA, a], [_sessionIdB, b]) => + a.userId.localeCompare(b.userId), + ) + .map(([sessionId, session]) => ( + {session.userId} - {session.sessionId} - - - {t( - 'boardBar.developerToolsDialog.communicationChannelStatistics.whiteboardSessionsTable.expire', - 'Session will expire in {{expire}}.', - { expire: formatTime(session.expiresTs - Date.now()) }, - )} + {sessionId} )) @@ -94,10 +83,3 @@ export function WhiteboardSessionsTable({ ); } - -function formatTime(milliseconds: number): string { - const minutes = Math.floor(milliseconds / 60000); - const seconds = Math.floor((milliseconds % 60000) / 1000); - - return `${minutes}m ${seconds}s`; -} diff --git a/packages/react-sdk/src/components/Whiteboard/index.ts b/packages/react-sdk/src/components/Whiteboard/index.ts index 96386f2fe..c910e3c17 100644 --- a/packages/react-sdk/src/components/Whiteboard/index.ts +++ b/packages/react-sdk/src/components/Whiteboard/index.ts @@ -17,7 +17,6 @@ export { gridCellSize, infiniteCanvasMode, - matrixRtcMode, whiteboardHeight, whiteboardWidth, } from './constants'; diff --git a/packages/react-sdk/src/lib/index.ts b/packages/react-sdk/src/lib/index.ts index af7f71321..73406edc4 100644 --- a/packages/react-sdk/src/lib/index.ts +++ b/packages/react-sdk/src/lib/index.ts @@ -22,7 +22,7 @@ export { acquireImageUrl, releaseImageUrl } from './imageBlobCache'; export { isDefined } from './isDefined'; export { isInfiniteCanvasMode } from './isInfiniteCanvasMode'; export { setLocale } from './locale'; -export { WidgetApiActionError, convertMxcToHttpUrl } from './matrix'; +export * from './matrix'; export { isMatrixRtcMode } from './matrixRtcMode'; export { findColor, useColorPalette } from './useColorPalette'; export type { Color } from './useColorPalette'; diff --git a/packages/react-sdk/src/lib/matrix/getServerNameFromUserId.test.ts b/packages/react-sdk/src/lib/matrix/getServerNameFromUserId.test.ts new file mode 100644 index 000000000..3ebb50a63 --- /dev/null +++ b/packages/react-sdk/src/lib/matrix/getServerNameFromUserId.test.ts @@ -0,0 +1,26 @@ +/* + * Copyright 2026 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { getServerNameFromUserId } from './getServerNameFromUserId'; + +describe('getServerNameFromUserId', () => { + it('should get a server name from user id', () => { + expect(getServerNameFromUserId('@user-id:example.com')).toEqual( + 'example.com', + ); + }); +}); diff --git a/packages/react-sdk/src/lib/matrix/getServerNameFromUserId.ts b/packages/react-sdk/src/lib/matrix/getServerNameFromUserId.ts new file mode 100644 index 000000000..2441fa246 --- /dev/null +++ b/packages/react-sdk/src/lib/matrix/getServerNameFromUserId.ts @@ -0,0 +1,23 @@ +/* + * Copyright 2026 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Gets a server name from user id, example: @user-id:example.com -> example.com + * @param userId matrix user id + */ +export function getServerNameFromUserId(userId: string): string { + return userId.split(':').splice(1).join(':'); +} diff --git a/packages/react-sdk/src/lib/matrix/index.ts b/packages/react-sdk/src/lib/matrix/index.ts index 4a20dc2b8..f51919c90 100644 --- a/packages/react-sdk/src/lib/matrix/index.ts +++ b/packages/react-sdk/src/lib/matrix/index.ts @@ -15,4 +15,6 @@ */ export { convertMxcToHttpUrl } from './convertMxcToHttpUrl'; +export { getServerNameFromUserId } from './getServerNameFromUserId'; +export { matrixRtcParticipantIdentity } from './matrixRtcParticipantIdentity'; export { WidgetApiActionError } from './WidgetApiActionError'; diff --git a/packages/react-sdk/src/lib/matrix/matrixRtcParticipantIdentity.test.ts b/packages/react-sdk/src/lib/matrix/matrixRtcParticipantIdentity.test.ts new file mode 100644 index 000000000..72a3cb505 --- /dev/null +++ b/packages/react-sdk/src/lib/matrix/matrixRtcParticipantIdentity.test.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2026 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { matrixRtcParticipantIdentity } from './matrixRtcParticipantIdentity'; + +describe('matrixRtcParticipantIdentity', () => { + it('should return identity as in msc4195', async () => { + const result = await matrixRtcParticipantIdentity( + '@alice:example.com', + 'DEVICE123', + 'memberABC', + ); + expect(result).toBe('J+T45tGruxc+HrUOqJJlyQSV33m728Cme4+vt8/SWrU'); + }); + + it('should return identity for @user-id:example.com', async () => { + const result = await matrixRtcParticipantIdentity( + '@user-id:example.com', + 'DEVICE1', + 'memberA', + ); + expect(result).toBe('vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw'); + }); + + it('should return identity for @another-user-id:example.com', async () => { + const result = await matrixRtcParticipantIdentity( + '@another-user-id:example.com', + 'DEVICE2', + 'memberB', + ); + expect(result).toBe('sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0'); + }); +}); diff --git a/packages/react-sdk/src/lib/matrix/matrixRtcParticipantIdentity.ts b/packages/react-sdk/src/lib/matrix/matrixRtcParticipantIdentity.ts new file mode 100644 index 000000000..34a86f12c --- /dev/null +++ b/packages/react-sdk/src/lib/matrix/matrixRtcParticipantIdentity.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2026 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Base64 } from 'js-base64'; + +/** + * Calculates pseudonymous identity as unpadded base64 encoding of the + * SHA-256 hash of the JSON serialization of an array containing the + * Matrix user id, device id, member id. + * @param userId user id + * @param deviceId device id + * @param memberId member id + * @returns pseudonymous identity + */ +export async function matrixRtcParticipantIdentity( + userId: string, + deviceId: string, + memberId: string, +): Promise { + const jsonString = JSON.stringify([userId, deviceId, memberId]); + const uint8Array = await sha256Digest(jsonString); + return Base64.fromUint8Array(uint8Array).replace(/=+$/, ''); +} + +async function sha256Digest(text: string): Promise { + const data = new TextEncoder().encode(text); + const buffer = await window.crypto.subtle.digest('SHA-256', data); + return new Uint8Array(buffer); +} diff --git a/packages/react-sdk/src/lib/matrixRtcMode.test.ts b/packages/react-sdk/src/lib/matrixRtcMode.test.ts deleted file mode 100644 index 3aabff48f..000000000 --- a/packages/react-sdk/src/lib/matrixRtcMode.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Copyright 2025 Nordeck IT + Consulting GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import { normalizeMatrixUserId } from './matrixRtcMode'; - -vi.mock('@matrix-widget-toolkit/mui', () => ({ - getEnvironment: vi.fn(), -})); - -import { getEnvironment } from '@matrix-widget-toolkit/mui'; - -describe('normalizeMatrixUserId', () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - afterEach(() => { - vi.resetAllMocks(); - }); - - it('returns unchanged if not a valid MXID in MatrixRTC mode', () => { - (getEnvironment as Mock).mockReturnValue('matrixrtc'); - - expect(normalizeMatrixUserId('@user:example.com')).toBe( - '@user:example.com', - ); - }); - - it('returns userId unchanged if not in MatrixRTC mode', () => { - (getEnvironment as Mock).mockReturnValue('webrtc'); - - expect(normalizeMatrixUserId('@user:example.com')).toBe( - '@user:example.com', - ); - expect(normalizeMatrixUserId('@user:example.com:sessionId')).toBe( - '@user:example.com:sessionId', - ); - }); - - it('return userId if in MatrixRTC mode and it has more than two parts', () => { - (getEnvironment as Mock).mockReturnValue('matrixrtc'); - - expect(normalizeMatrixUserId('@user:example.com')).toBe( - '@user:example.com', - ); - expect(normalizeMatrixUserId('@user:example.com:sessionId')).toBe( - '@user:example.com', - ); - expect(normalizeMatrixUserId('@user:example.com:session:extra')).toBe( - '@user:example.com', - ); - }); -}); diff --git a/packages/react-sdk/src/lib/matrixRtcMode.ts b/packages/react-sdk/src/lib/matrixRtcMode.ts index 72f1168d9..a8194fa69 100644 --- a/packages/react-sdk/src/lib/matrixRtcMode.ts +++ b/packages/react-sdk/src/lib/matrixRtcMode.ts @@ -19,12 +19,3 @@ import { getEnvironment } from '@matrix-widget-toolkit/mui'; export function isMatrixRtcMode(): boolean { return getEnvironment('REACT_APP_RTC') === 'matrixrtc'; } - -export function normalizeMatrixUserId(userId: string): string { - if (isMatrixRtcMode()) { - // in MatrixRTC mode, the userId has an additional livekit session component - // so we remove it (ie. @user:domain:sessionId -> @user:domain) - userId = userId.split(':', 2).join(':'); - } - return userId; -} diff --git a/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx b/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx index b1460d28e..39d53e3d6 100644 --- a/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx +++ b/packages/react-sdk/src/lib/testUtils/documentTestUtils.tsx @@ -139,23 +139,29 @@ export function mockWhiteboardManager( broadcastMessage: vi.fn(), observeMessages: vi.fn(() => messageSubject), getStatistics: vi.fn(() => ({ - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-alice:example.com', 'connected', ), }, + sessions: {}, })), observeStatistics: vi.fn(() => of({ - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-alice:example.com', 'connected', ), }, + sessions: {}, }), ), destroy: vi.fn(), @@ -294,11 +300,13 @@ export function mockPeerConnectionStatistics( remoteUserId: string, connectionState: string, remoteSessionId: string = 'other', + remoteParticipantIdentities?: string[], ): PeerConnectionStatistics { return { remoteUserId, connectionState, remoteSessionId, + remoteParticipantIdentities, impolite: true, bytesReceived: 0, bytesSent: 0, diff --git a/packages/react-sdk/src/lib/testUtils/livekitMock.ts b/packages/react-sdk/src/lib/testUtils/livekitMock.ts new file mode 100644 index 000000000..96107ae5b --- /dev/null +++ b/packages/react-sdk/src/lib/testUtils/livekitMock.ts @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import EventEmitter from 'events'; +import { + ConnectionState, + LocalParticipant, + RemoteParticipant, + Room, + RoomEvent, +} from 'livekit-client'; +import { Mocked, vi } from 'vitest'; + +export type MockLivekitRoom = Mocked & { + setConnectionState: (state: ConnectionState) => void; + emitEvent: (event: RoomEvent, ...args: unknown[]) => void; +}; + +export function mockLocalParticipant({ + identity = 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', +}: { identity?: string } = {}): Mocked { + return { + identity, + publishData: vi.fn(), + } as Partial as Mocked; +} + +export function mockLivekitRoom({ + localParticipant = mockLocalParticipant(), + remoteParticipants = new Map(), +}: { + localParticipant?: Mocked; + remoteParticipants?: Map; +} = {}): MockLivekitRoom { + const eventEmitter = new EventEmitter(); + + const room = { + connect: vi.fn(), + disconnect: vi.fn(), + + on: vi.fn((event, listener) => { + eventEmitter.on(event, listener); + return room; + }), + off: vi.fn((event, listener) => { + eventEmitter.off(event, listener); + return room; + }), + once: vi.fn((event, listener) => { + eventEmitter.once(event, listener); + return room; + }), + + state: ConnectionState.Disconnected, + localParticipant, + remoteParticipants, + } as Partial as Mocked; + + return Object.assign(room, { + setConnectionState: (state: ConnectionState) => { + room.state = state; + eventEmitter.emit(RoomEvent.ConnectionStateChanged, state); + }, + emitEvent: (event: RoomEvent, ...args: unknown[]) => { + eventEmitter.emit(event, ...args); + }, + }); +} diff --git a/packages/react-sdk/src/lib/testUtils/matrixRtcMock.ts b/packages/react-sdk/src/lib/testUtils/matrixRtcMock.ts deleted file mode 100644 index 727c3c01d..000000000 --- a/packages/react-sdk/src/lib/testUtils/matrixRtcMock.ts +++ /dev/null @@ -1,178 +0,0 @@ -/* - * Copyright 2025 Nordeck IT + Consulting GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { StateEvent } from '@matrix-widget-toolkit/api'; -import EventEmitter from 'events'; -import { - ConnectionState, - LocalParticipant, - Participant, - Room, - RoomEvent, -} from 'livekit-client'; -import { Mocked, vi } from 'vitest'; -import { RTCSessionEventContent } from '../../model'; -import { - RTC_APPLICATION_WHITEBOARD, - STATE_EVENT_RTC_MEMBER, -} from '../../model/matrixRtcSessions'; - -export type MockLocalParticipant = Mocked & { - emitEvent: (event: string, ...args: unknown[]) => void; -}; - -export type MockLivekitRoom = Mocked & { - eventEmitter: EventEmitter; - setConnectionState: (state: ConnectionState) => void; - emitEvent: (event: RoomEvent, ...args: unknown[]) => void; -}; - -export function mockLocalParticipant(): MockLocalParticipant { - const eventEmitter = new EventEmitter(); - - const mockParticipant = { - publishData: vi.fn().mockResolvedValue(undefined), - identity: 'local-participant', - sid: 'local-participant-sid', - metadata: '', - name: 'Local User', - on: vi.fn((event, listener) => { - eventEmitter.on(event, listener); - return mockParticipant; - }), - off: vi.fn((event, listener) => { - eventEmitter.off(event, listener); - return mockParticipant; - }), - once: vi.fn((event, listener) => { - eventEmitter.once(event, listener); - return mockParticipant; - }), - removeListener: vi.fn((event, listener) => { - eventEmitter.removeListener(event, listener); - return mockParticipant; - }), - emit: vi.fn((event, ...args) => eventEmitter.emit(event, ...args)), - emitEvent: (event: string, ...args: unknown[]) => { - eventEmitter.emit(event, ...args); - }, - } as unknown as MockLocalParticipant; - - return mockParticipant; -} - -export function mockLivekitRoom(): MockLivekitRoom { - const eventEmitter = new EventEmitter(); - let connectionState: ConnectionState = ConnectionState.Disconnected; - const localParticipant = mockLocalParticipant(); - const participants = new Map(); - - const mockRoom = { - connect: vi - .fn() - .mockImplementation(async (_url: string, _token: string) => { - mockRoom.setConnectionState(ConnectionState.Connecting); - setTimeout(() => { - mockRoom.setConnectionState(ConnectionState.Connected); - mockRoom.emitEvent(RoomEvent.Connected); - }, 10); - return Promise.resolve(); - }), - disconnect: vi.fn().mockImplementation(() => { - mockRoom.setConnectionState(ConnectionState.Disconnected); - mockRoom.emitEvent(RoomEvent.Disconnected); - }), - - on: vi.fn((event, listener) => { - eventEmitter.on(event, listener); - return mockRoom; - }), - off: vi.fn((event, listener) => { - eventEmitter.off(event, listener); - return mockRoom; - }), - once: vi.fn((event, listener) => { - eventEmitter.once(event, listener); - return mockRoom; - }), - - state: connectionState, - localParticipant, - participants, - - eventEmitter, - setConnectionState: (state: ConnectionState) => { - connectionState = state; - mockRoom.state = state; - mockRoom.emitEvent(RoomEvent.ConnectionStateChanged, state); - }, - emitEvent: (event: RoomEvent, ...args: unknown[]) => { - eventEmitter.emit(event, ...args); - }, - } as unknown as MockLivekitRoom; - - return mockRoom; -} - -/** - * Create a whiteboard membership state event. - * - * @remarks Only use for tests - */ -export function mockWhiteboardMembership( - { - content = {}, - state_key = '_@user-id_DEVICEID', - sender = '@user-id:example.com', - origin_server_ts = 0, - }: { - content?: RTCSessionEventContent | Partial; - state_key?: string; - sender?: string; - origin_server_ts?: number; - } = {}, - overrideContent: boolean = false, -): StateEvent { - const defaultContent: RTCSessionEventContent = { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - foci_preferred: [ - { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }, - ], - scope: 'm.room', - expires: +new Date('2050-01-12T11:25:20.143Z').getTime(), - }; - - return { - type: STATE_EVENT_RTC_MEMBER, - sender, - content: overrideContent - ? (content as RTCSessionEventContent) - : { ...defaultContent, ...content }, - state_key, - origin_server_ts, - event_id: '$event-id-0', - room_id: '!room-id:example.com', - }; -} diff --git a/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts b/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts index 38fbf6c7d..0080a304e 100644 --- a/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts +++ b/packages/react-sdk/src/lib/testUtils/matrixTestUtils.ts @@ -29,7 +29,12 @@ import { DocumentChunk, DocumentCreate, DocumentSnapshot, + ROOM_EVENT_4143_RTC_MEMBER, RoomNameEvent, + RtcMember, + RtcMemberJoin, + RtcMemberLeave, + Transport, Whiteboard, WhiteboardSession, WhiteboardSessions, @@ -444,6 +449,97 @@ export function mockWhiteboardSessions({ }; } +export function mockRtcMember({ + sender = '@user-id:example.com', + content = mockRtcMemberJoinContent(), + event_id = '$event-id-0', + origin_server_ts = 0, + room_id = '!room-id:example.com', + stickyDurationMs = 3600000, +}: { + sender?: string; + content?: RtcMember; + event_id?: string; + origin_server_ts?: number; + room_id?: string; + stickyDurationMs?: number; +}): RoomEvent { + return { + type: ROOM_EVENT_4143_RTC_MEMBER, + sender, + content, + origin_server_ts, + event_id, + room_id, + msc4354_sticky: { + duration_ms: stickyDurationMs, + }, + }; +} + +export function mockRtcMemberJoinContent({ + whiteboardId = 'whiteboard-id', + memberId = '$member-id-0', + deviceId = '$device-id-0', + transports = { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, +}: { + whiteboardId?: string; + memberId?: string; + deviceId?: string; + transports?: { + published: Transport[]; + can_subscribe: string[]; + }; +} = {}): RtcMemberJoin { + return { + slot_id: `net.nordeck.whiteboard#${whiteboardId}`, + member: { + id: memberId, + membership: 'join', + device_id: deviceId, + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: whiteboardId, + }, + transports, + msc4354_sticky_key: memberId, + }; +} + +export function mockRtcMemberLeaveContent({ + whiteboardId = 'whiteboard-id', + memberId = '$member-id-0', + deviceId = '$device-id-0', + leaveReasonCode = 'leave', +}: { + whiteboardId?: string; + memberId?: string; + deviceId?: string; + leaveReasonCode?: 'leave' | 'delayed_leave' | 'slot_closed'; +} = {}): RtcMemberLeave { + return { + slot_id: `net.nordeck.whiteboard#${whiteboardId}`, + member: { + id: memberId, + membership: 'leave', + device_id: deviceId, + }, + leave_reason: { + code: leaveReasonCode, + }, + msc4354_sticky_key: memberId, + }; +} + /** * Create a whiteboard document with known test data. * diff --git a/packages/react-sdk/src/lib/userColor.ts b/packages/react-sdk/src/lib/userColor.ts index f7e6fb08e..78e5f36cf 100644 --- a/packages/react-sdk/src/lib/userColor.ts +++ b/packages/react-sdk/src/lib/userColor.ts @@ -35,7 +35,6 @@ import { teal, yellow, } from '@mui/material/colors'; -import { normalizeMatrixUserId } from './matrixRtcMode'; const colors = [ amber[500], @@ -70,7 +69,7 @@ function hashUserId(userId: string): number { } export function getUserColor(userId: string): string { - const hash = hashUserId(normalizeMatrixUserId(userId)); + const hash = hashUserId(userId); const index = hash % colors.length; return colors[index]; } diff --git a/packages/react-sdk/src/locales/de/neoboard.json b/packages/react-sdk/src/locales/de/neoboard.json index c9fe06d3c..4c1ae3a24 100644 --- a/packages/react-sdk/src/locales/de/neoboard.json +++ b/packages/react-sdk/src/locales/de/neoboard.json @@ -15,25 +15,27 @@ "dataChannel": "Datenkanal", "dataTransfer": "Datenübertragung", "impolite": "Unhöflich", + "localMemberId": "Lokale Mitglieds-ID", + "localParticipantIdentity": "Lokale Teilnehmer-Identität", "localSessionId": "Lokale Sitzungs-ID", + "localSfuConnectionId": "Lokale SFU-Verbindungs-ID", "messagesReceived": "Empfangene Nachrichten", "peers": "Teilnehmer", "received": "Empfangen", + "remoteParticipantIdentities": "Entfernte Teilnehmer-Identitäten", "remoteSessionId": "Entfernte Sitzungs-ID", + "remoteUserId": "Entfernte Benutzer-ID", "signaling": "Signalisierung", "tableAriaLabel": "Statistiken der Kommunikationskanäle", "true": "wahr", - "userId": "Benutzer-ID", "whiteboardSessionsTable": { - "expire": "Die Sitzung läuft in {{expire}} ab.", - "expiresState": "Status", "noData": "Keine Whiteboard-Sitzungen verfügbar", "sessionId": "Sitzungs", "tableAriaLabel": "Whiteboard-Sitzungen", "userId": "Benutzer-ID" } }, - "communicationChannelStatisticsTitle": "Statistiken der Kommunikationskanäle (WRTC)", + "communicationChannelStatisticsTitle": "Statistiken der Kommunikationskanäle", "documentSyncStatistics": { "binaryJsonSize": "Binär / JSON-Größe (Bytes)", "false": "falsch", diff --git a/packages/react-sdk/src/locales/en/neoboard.json b/packages/react-sdk/src/locales/en/neoboard.json index daf382126..503c5af93 100644 --- a/packages/react-sdk/src/locales/en/neoboard.json +++ b/packages/react-sdk/src/locales/en/neoboard.json @@ -15,25 +15,27 @@ "dataChannel": "Data Channel", "dataTransfer": "Data Transfer", "impolite": "Impolite", + "localMemberId": "Local Member Id", + "localParticipantIdentity": "Local Participant Identity", "localSessionId": "Local Session Id", + "localSfuConnectionId": "Local SFU Connection Id", "messagesReceived": "Messages Received", "peers": "Peers", "received": "Received", + "remoteParticipantIdentities": "Remote Participant Identities", "remoteSessionId": "Remote Session Id", + "remoteUserId": "Remote User Id", "signaling": "Signaling", "tableAriaLabel": "Communication Channel Statistics", "true": "true", - "userId": "User Id", "whiteboardSessionsTable": { - "expire": "Session will expire in {{expire}}.", - "expiresState": "State", "noData": "No whiteboard sessions available", "sessionId": "Session ID", "tableAriaLabel": "Whiteboard Sessions", "userId": "User ID" } }, - "communicationChannelStatisticsTitle": "Communication Channel Statistics (WRTC)", + "communicationChannelStatisticsTitle": "Communication Channel Statistics", "documentSyncStatistics": { "binaryJsonSize": "Binary / JSON Size (bytes)", "false": "false", diff --git a/packages/react-sdk/src/model/index.ts b/packages/react-sdk/src/model/index.ts index a98d0bfc9..ed8256965 100644 --- a/packages/react-sdk/src/model/index.ts +++ b/packages/react-sdk/src/model/index.ts @@ -36,13 +36,24 @@ export { } from './documentSnapshot'; export type { DocumentSnapshot } from './documentSnapshot'; export { - STATE_EVENT_RTC_MEMBER, - isRTCSessionNotExpired, - isValidRTCSessionStateEvent, - isWhiteboardRTCSessionStateEvent, - newRTCSession, -} from './matrixRtcSessions'; -export type { RTCSessionEventContent } from './matrixRtcSessions'; + ROOM_EVENT_4143_RTC_MEMBER, + isLivekitTransport, + isRtcMemberJoinEvent, + isRtcMemberLeaveEvent, + isValidWhiteboardRtcMemberEvent, +} from './matrixRtcMember'; +export type { + LivekitTransport, + RtcMember, + RtcMemberJoin, + RtcMemberLeave, + Transport, +} from './matrixRtcMember'; +export { + STATE_EVENT_4143_RTC_SLOT, + isValidWhiteboardRtcSlotEvent, +} from './matrixRtcSlot'; +export type { RtcSlot } from './matrixRtcSlot'; export { STATE_EVENT_ROOM_NAME, isValidRoomNameEvent } from './roomNameEvent'; export type { RoomNameEvent } from './roomNameEvent'; export { diff --git a/packages/react-sdk/src/model/matrixRtcMember.test.ts b/packages/react-sdk/src/model/matrixRtcMember.test.ts new file mode 100644 index 000000000..95f485179 --- /dev/null +++ b/packages/react-sdk/src/model/matrixRtcMember.test.ts @@ -0,0 +1,429 @@ +/* + * Copyright 2026 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { + isLivekitTransport, + isValidWhiteboardRtcMemberEvent, +} from './matrixRtcMember'; + +describe('isValidWhiteboardRtcMemberEvent', () => { + it('should accept rtc member join event', () => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'join', + device_id: '$device-id-0', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + }), + ).toBe(true); + }); + + it.each([ + { slot_id: undefined }, + { slot_id: 111 }, + { member: undefined }, + { application: undefined }, + { transports: undefined }, + { transports: [] }, + { msc4354_sticky_key: undefined }, + ])( + 'should reject rtc member join event with content patch %js', + (patch: object) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'join', + device_id: '$device-id-0', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: '$member-id-0', + ...patch, + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + }), + ).toBe(false); + }, + ); + + it.each([ + { id: undefined }, + { id: 111 }, + { membership: undefined }, + { membership: 'something' }, + { device_id: undefined }, + { device_id: 111 }, + ])( + 'should reject rtc member join event with member patch %js', + (patch: object) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'join', + device_id: '$device-id-0', + ...patch, + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + }), + ).toBe(false); + }, + ); + + it.each([ + { type: undefined }, + { type: 111 }, + { type: 'net.other.application' }, + { whiteboard_id: undefined }, + { whiteboard_id: 111 }, + ])( + 'should reject rtc member join event with application patch %js', + (patch: object) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'join', + device_id: '$device-id-0', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + ...patch, + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + msc4354_sticky: { + duration_ms: 3600000, + }, + }), + ).toBe(false); + }, + ); + + it.each([ + { published: undefined }, + { published: {} }, + { can_subscribe: [] }, + { can_subscribe: ['something'] }, + ])( + 'should reject rtc member join event with transports patch %js', + (patch: object) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'join', + device_id: '$device-id-0', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + ...patch, + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + msc4354_sticky: { + duration_ms: 3600000, + }, + }), + ).toBe(false); + }, + ); + + it('should accept rtc member leave event', () => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'leave', + device_id: '$device-id-0', + }, + leave_reason: { + code: 'leave', + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + msc4354_sticky: { + duration_ms: 3600000, + }, + }), + ).toBe(true); + }); + + it.each(['leave', 'delayed_leave', 'slot_closed'])( + 'should accept rtc member leave event with code %s', + (code) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'leave', + device_id: '$device-id-0', + }, + leave_reason: { + code: code, + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + msc4354_sticky: { + duration_ms: 3600000, + }, + }), + ).toBe(true); + }, + ); + + it.each([ + { slot_id: undefined }, + { slot_id: 111 }, + { member: undefined }, + { member: 111 }, + { leave_reason: 111 }, + { msc4354_sticky_key: undefined }, + ])('should reject rtc member leave event with content patch %j', (patch) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'leave', + device_id: '$device-id-0', + }, + leave_reason: { + code: 'leave', + }, + msc4354_sticky_key: '$member-id-0', + ...patch, + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + msc4354_sticky: { + duration_ms: 3600000, + }, + }), + ).toBe(false); + }); + + it.each([ + { id: undefined }, + { id: 111 }, + { membership: undefined }, + { membership: 'something' }, + { device_id: undefined }, + { device_id: 111 }, + ])( + 'should reject rtc member leave event with member patch %js', + (patch: object) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'leave', + device_id: '$device-id-0', + ...patch, + }, + leave_reason: { + code: 'leave', + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + msc4354_sticky: { + duration_ms: 3600000, + }, + }), + ).toBe(false); + }, + ); + + it.each([{ code: undefined }, { code: 111 }])( + 'should reject rtc member leave event with member patch %js', + (patch: object) => { + expect( + isValidWhiteboardRtcMemberEvent({ + type: 'org.matrix.msc4143.rtc.member', + sender: '@user-id:example.com', + content: { + slot_id: `net.nordeck.whiteboard#whiteboard-id`, + member: { + id: '$member-id-0', + membership: 'leave', + device_id: '$device-id-0', + }, + leave_reason: { + code: 'leave', + ...patch, + }, + msc4354_sticky_key: '$member-id-0', + }, + origin_server_ts: 0, + event_id: '$event-id', + room_id: '!room-id:example.com', + msc4354_sticky: { + duration_ms: 3600000, + }, + }), + ).toBe(false); + }, + ); +}); + +describe('isLivekitTransport', () => { + it('should return true for livekit transport', () => { + expect( + isLivekitTransport({ + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }), + ).toBe(true); + }); + + it('should return false for invalid livekit transport', () => { + expect( + isLivekitTransport({ + type: 'livekit', + }), + ).toBe(false); + }); + + it('should return false for other transport', () => { + expect( + isLivekitTransport({ + type: 'something', + }), + ).toBe(false); + }); +}); diff --git a/packages/react-sdk/src/model/matrixRtcMember.ts b/packages/react-sdk/src/model/matrixRtcMember.ts new file mode 100644 index 000000000..08c930270 --- /dev/null +++ b/packages/react-sdk/src/model/matrixRtcMember.ts @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { RoomEvent } from '@matrix-widget-toolkit/api'; +import Joi from 'joi'; +import { isValidEvent } from './validation'; + +export const ROOM_EVENT_4143_RTC_MEMBER = 'org.matrix.msc4143.rtc.member'; + +type RtcMemberBase = { + slot_id: string; + msc4354_sticky_key: string; +}; + +const rtcMemberBaseSchema = Joi.object({ + slot_id: Joi.string().required(), + msc4354_sticky_key: Joi.string().required(), +}) + .unknown() + .required(); + +export type Transport = { + type: string; + [key: string]: unknown; +}; + +const transportSchema = Joi.object({ + type: Joi.string().required(), +}).unknown(); + +export type LivekitTransport = Transport & { + type: 'livekit'; + livekit_service_url: string; +}; + +export type RtcMember = RtcMemberJoin | RtcMemberLeave; + +export type RtcMemberJoin = RtcMemberBase & { + member: { + id: string; + membership: 'join'; + device_id: string; + }; + application: { + type: 'net.nordeck.whiteboard'; + whiteboard_id: string; + }; + transports: { + published: Transport[]; + can_subscribe: string[]; + }; +}; + +const rtcMemberJoinSchema = rtcMemberBaseSchema + .append({ + member: Joi.object({ + id: Joi.string().required(), + membership: Joi.string().valid('join').required(), + device_id: Joi.string().required(), + }) + .unknown() + .required(), + application: Joi.object({ + type: Joi.string().valid('net.nordeck.whiteboard').required(), + whiteboard_id: Joi.string().required(), + }) + .unknown() + .required(), + transports: Joi.object({ + published: Joi.array().items(transportSchema).required(), + can_subscribe: Joi.array() + .items(Joi.string().valid('livekit')) + .length(1) + .required(), + }) + .unknown() + .required(), + }) + .unknown() + .required(); + +export type RtcMemberLeave = RtcMemberBase & { + member: { + id: string; + membership: 'leave'; + device_id: string; + }; + leave_reason?: { + code: 'leave' | 'delayed_leave' | 'slot_closed'; + reason?: string; + }; +}; + +const rtcMemberLeaveSchema = rtcMemberBaseSchema.append({ + member: Joi.object({ + id: Joi.string().required(), + membership: Joi.string().valid('leave').required(), + device_id: Joi.string().required(), + }) + .unknown() + .required(), + leave_reason: Joi.object({ + code: Joi.string() + .valid('leave', 'delayed_leave', 'slot_closed') + .required(), + reason: Joi.string(), + }).unknown(), +}); + +const rtcMemberSchema = Joi.alternatives().conditional( + '.member.membership', + [ + { is: 'join', then: rtcMemberJoinSchema }, + { is: 'leave', then: rtcMemberLeaveSchema }, + ], +); + +export function isValidWhiteboardRtcMemberEvent( + event: RoomEvent, +): event is RoomEvent { + return isValidEvent(event, ROOM_EVENT_4143_RTC_MEMBER, rtcMemberSchema); +} + +export function isRtcMemberJoinEvent( + rtcMemberEvent: RoomEvent, +): rtcMemberEvent is RoomEvent { + return rtcMemberEvent.content.member.membership === 'join'; +} + +export function isRtcMemberLeaveEvent( + rtcMemberEvent: RoomEvent, +): rtcMemberEvent is RoomEvent { + return rtcMemberEvent.content.member.membership === 'leave'; +} + +export function isLivekitTransport( + transport: Transport, +): transport is LivekitTransport { + return transport.type === 'livekit' && 'livekit_service_url' in transport; +} diff --git a/packages/react-sdk/src/model/matrixRtcSessions.test.ts b/packages/react-sdk/src/model/matrixRtcSessions.test.ts deleted file mode 100644 index d0e4ab167..000000000 --- a/packages/react-sdk/src/model/matrixRtcSessions.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright 2025 Nordeck IT + Consulting GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mockWhiteboardMembership } from '../lib/testUtils/matrixRtcMock'; -import { - DEFAULT_RTC_EXPIRE_DURATION, - isRTCSessionNotExpired, - isValidRTCSessionStateEvent, - isWhiteboardRTCSessionStateEvent, - newRTCSession, - RTC_APPLICATION_WHITEBOARD, -} from './matrixRtcSessions'; - -describe('isValidRTCSessionStateEvent', () => { - const fixedDate = 1742832000; - - beforeEach(() => { - vi.spyOn(Date, 'now').mockImplementation(() => fixedDate); - }); - - afterEach(() => { - vi.spyOn(Date, 'now').mockRestore(); - }); - - it('should accept RTC session event', () => { - expect(isValidRTCSessionStateEvent(mockWhiteboardMembership())).toBe(true); - }); - - it('should ignore other apps RTC membership events', () => { - expect( - isWhiteboardRTCSessionStateEvent( - mockWhiteboardMembership({ - content: { - application: 'other-app', - }, - }), - ), - ).toBe(false); - }); - - it('should accept NeoBoard RTC membership events', () => { - expect(isWhiteboardRTCSessionStateEvent(mockWhiteboardMembership())).toBe( - true, - ); - }); - - it('should recognize expired sessions', () => { - const notExpiredEvent = mockWhiteboardMembership({ - content: { - expires: fixedDate + DEFAULT_RTC_EXPIRE_DURATION, - }, - }); - expect(isRTCSessionNotExpired(notExpiredEvent)).toBe(true); - - const expiredEvent = mockWhiteboardMembership({ - content: { - expires: fixedDate - 1, - }, - }); - expect(isRTCSessionNotExpired(expiredEvent)).toBe(false); - }); - - it('should create a new RTC session', () => { - const event = newRTCSession('DEVICEID', 'whiteboard-id'); - expect(event).toEqual({ - call_id: 'whiteboard-id', - scope: 'm.room', - application: RTC_APPLICATION_WHITEBOARD, - device_id: 'DEVICEID', - expires: fixedDate + DEFAULT_RTC_EXPIRE_DURATION, - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - foci_preferred: [], - }); - }); -}); diff --git a/packages/react-sdk/src/model/matrixRtcSessions.ts b/packages/react-sdk/src/model/matrixRtcSessions.ts deleted file mode 100644 index a99cd4a85..000000000 --- a/packages/react-sdk/src/model/matrixRtcSessions.ts +++ /dev/null @@ -1,107 +0,0 @@ -/* - * Copyright 2025 Nordeck IT + Consulting GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { StateEvent } from '@matrix-widget-toolkit/api'; -import Joi from 'joi'; -import { RTCFocus } from '../state/communication'; -import { isValidEvent } from './validation'; - -export const RTC_APPLICATION_WHITEBOARD = 'net.nordeck.whiteboard'; - -export const DEFAULT_RTC_EXPIRE_DURATION = 1000 * 60 * 60 * 4; - -// unstable prefix for m.rtc.member in MSC4143 -export const STATE_EVENT_RTC_MEMBER = 'org.matrix.msc3401.call.member'; - -// Following Matrix JS SDK's SessionMembershipData -// see https://github.com/matrix-org/matrix-js-sdk/blob/d6ede767c929f7be179d456b5a0433be21ccaf7c/src/matrixrtc/CallMembership.ts#L35 -// with some changes, such as not including `created_ts` and making `expires` and 'scope' required -export type RTCSessionEventContent = { - application: string; - call_id: string; - device_id: string; - focus_active: RTCFocus; - foci_preferred: RTCFocus[]; - scope: string; - expires: number; -}; - -export type RTCSessions = { - sessions: RTCSessionEventContent[]; -}; - -const rtcSessionEventContentSchema = Joi.alternatives().try( - // Empty object option - Joi.object().max(0), - - // Full object option with all the requirements - Joi.object({ - application: Joi.string().required(), - call_id: Joi.string().required(), - device_id: Joi.string().required(), - focus_active: Joi.object().required(), - foci_preferred: Joi.array().items(Joi.object()).required(), - scope: Joi.string().required(), - expires: Joi.number().required(), - }).unknown(), -); - -export function isValidRTCSessionStateEvent( - event: StateEvent, -): event is StateEvent { - return isValidEvent( - event, - STATE_EVENT_RTC_MEMBER, - rtcSessionEventContentSchema, - true, - ); -} - -export function isWhiteboardRTCSessionStateEvent( - event: StateEvent, -): event is StateEvent { - return ( - isValidRTCSessionStateEvent(event) && - (event.content.application === RTC_APPLICATION_WHITEBOARD || - Object.keys(event.content).length === 0) - ); -} - -export function isRTCSessionNotExpired( - event: StateEvent, -): boolean { - if (!event.content || Object.keys(event.content).length === 0) { - return false; - } - return ( - event.content.expires !== undefined && event.content.expires > Date.now() - ); -} - -export function newRTCSession( - deviceId: string, - whiteboardId: string, -): RTCSessionEventContent { - return { - application: RTC_APPLICATION_WHITEBOARD, - call_id: whiteboardId, // if empty, it will be picked up by the JS SDK for calls - device_id: deviceId, - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - foci_preferred: [], - scope: 'm.room', - expires: Date.now() + DEFAULT_RTC_EXPIRE_DURATION, - }; -} diff --git a/packages/react-sdk/src/model/matrixRtcSlot.test.ts b/packages/react-sdk/src/model/matrixRtcSlot.test.ts new file mode 100644 index 000000000..fde391a15 --- /dev/null +++ b/packages/react-sdk/src/model/matrixRtcSlot.test.ts @@ -0,0 +1,110 @@ +/* + * Copyright 2022 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, expect, it } from 'vitest'; +import { + isValidWhiteboardRtcSlotEvent, + STATE_EVENT_4143_RTC_SLOT, +} from './matrixRtcSlot'; + +describe('isValidWhiteboardRtcSlotEvent', () => { + it('should accept event', () => { + expect( + isValidWhiteboardRtcSlotEvent({ + content: { + status: 'open', + application: { + type: 'net.nordeck.whiteboard', + }, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + state_key: 'net.nordeck.whiteboard#widget-id', + sender: '@user-id:example.com', + type: STATE_EVENT_4143_RTC_SLOT, + }), + ).toBe(true); + }); + + it('should accept additional properties', () => { + expect( + isValidWhiteboardRtcSlotEvent({ + content: { + status: 'open', + application: { + type: 'net.nordeck.whiteboard', + additional: 'tmp', + }, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + state_key: 'net.nordeck.whiteboard#widget-id', + sender: '@user-id:example.com', + type: STATE_EVENT_4143_RTC_SLOT, + }), + ).toBe(true); + }); + + it.each([ + { status: undefined }, + { status: 'other' }, + { application: undefined }, + { application: 111 }, + ])('should reject event with content patch %j', (patch: object) => { + expect( + isValidWhiteboardRtcSlotEvent({ + content: { + status: 'open', + application: { + type: 'net.nordeck.whiteboard', + }, + ...patch, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + state_key: 'net.nordeck.whiteboard#widget-id', + sender: '@user-id:example.com', + type: STATE_EVENT_4143_RTC_SLOT, + }), + ).toBe(false); + }); + + it.each([{ type: undefined }, { type: 'other' }])( + 'should reject event with application patch %j', + (patch: object) => { + expect( + isValidWhiteboardRtcSlotEvent({ + content: { + status: 'open', + application: { + type: 'net.nordeck.whiteboard', + ...patch, + }, + }, + event_id: '$event-id', + origin_server_ts: 0, + room_id: '!room-id:example.com', + state_key: 'net.nordeck.whiteboard#widget-id', + sender: '@user-id:example.com', + type: STATE_EVENT_4143_RTC_SLOT, + }), + ).toBe(false); + }, + ); +}); diff --git a/packages/react-sdk/src/model/matrixRtcSlot.ts b/packages/react-sdk/src/model/matrixRtcSlot.ts new file mode 100644 index 000000000..3f7ba5fb0 --- /dev/null +++ b/packages/react-sdk/src/model/matrixRtcSlot.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2026 Nordeck IT + Consulting GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { StateEvent } from '@matrix-widget-toolkit/api'; +import Joi from 'joi'; +import { isValidEvent } from './validation'; + +export const STATE_EVENT_4143_RTC_SLOT = 'org.matrix.msc4143.rtc.slot'; + +export type RtcSlot = { + status: string; + application: { + type: 'net.nordeck.whiteboard'; + }; +}; + +const rtcSlotSchema = Joi.object({ + status: Joi.string().valid('open').required(), + application: Joi.object({ + type: Joi.string().valid('net.nordeck.whiteboard').required(), + }) + .unknown() + .required(), +}).unknown(); + +export function isValidWhiteboardRtcSlotEvent( + event: StateEvent, +): event is StateEvent { + return isValidEvent(event, STATE_EVENT_4143_RTC_SLOT, rtcSlotSchema); +} diff --git a/packages/react-sdk/src/state/communication/connection/index.ts b/packages/react-sdk/src/state/communication/connection/index.ts index f76f84964..54527bf2e 100644 --- a/packages/react-sdk/src/state/communication/connection/index.ts +++ b/packages/react-sdk/src/state/communication/connection/index.ts @@ -17,6 +17,7 @@ export { MatrixRtcPeerConnection } from './matrixRtcPeerConnection'; export type { Message, + MessageOptions, PeerConnection, PeerConnectionStatistics, } from './types'; diff --git a/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.test.ts b/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.test.ts index a10438bbc..c1e530571 100644 --- a/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.test.ts +++ b/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.test.ts @@ -14,48 +14,72 @@ * limitations under the License. */ -import { ConnectionState, RoomEvent } from 'livekit-client'; -import { bufferTime, firstValueFrom, toArray } from 'rxjs'; +import { + ConnectionState, + RemoteParticipant, + Room, + RoomEvent, +} from 'livekit-client'; +import { firstValueFrom, toArray } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { MockLivekitRoom, mockLivekitRoom, -} from '../../../lib/testUtils/matrixRtcMock'; -import { Session } from '../discovery'; -import { SFUConfig } from '../matrixRtcCommunicationChannel'; +} from '../../../lib/testUtils/livekitMock'; import { MatrixRtcPeerConnection } from './matrixRtcPeerConnection'; +vi.mock('livekit-client', async () => ({ + ...(await vi.importActual('livekit-client')), + Room: vi.fn(), +})); + describe('MatrixRtcPeerConnection', () => { + let remoteParticipants: Map; let mockRoom: MockLivekitRoom; - const session: Session = { - sessionId: 'session-a', - userId: '@user-id:example.com', - }; - const sfuConfig: SFUConfig = { - url: 'wss://livekit-server', - jwt: 'dummy-jwt', - }; + let livekitServiceUrl: string; + let livekitUrl: string; + let livekitToken: string; + let getUserId: (sessionId: string) => string | undefined; beforeEach(() => { - vi.stubEnv('REACT_APP_RTC', 'matrixrtc'); - mockRoom = mockLivekitRoom(); - }); - - afterEach(() => { - vi.unstubAllEnvs(); + remoteParticipants = new Map(); + mockRoom = mockLivekitRoom({ + remoteParticipants, + }); + livekitServiceUrl = 'https://livekit-jwt.example.com'; + livekitUrl = 'wss://livekit-server'; + livekitToken = 'dummy-jwt'; + getUserId = vi.fn().mockImplementation((sessionId) => { + if (sessionId === 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0') { + return '@another-user-id:example.com'; + } else if (sessionId === 'J+T45tGruxc+HrUOqJJlyQSV33m728Cme4+vt8/SWrU') { + return '@alice:example.com'; + } + + return undefined; + }); + vi.mocked(Room).mockImplementation(() => mockRoom); }); - it('should try to connect to LiveKit on creation', async () => { - const connection = new MatrixRtcPeerConnection(session, sfuConfig); - const spy = vi.spyOn(connection.room, 'connect'); - - await vi.waitFor(() => expect(spy).toHaveBeenCalledOnce()); + it('should connect on creation', async () => { + const connection = new MatrixRtcPeerConnection( + livekitServiceUrl, + livekitUrl, + livekitToken, + getUserId, + ); + expect(mockRoom.connect).toHaveBeenCalledOnce(); connection.close(); }); it('should close connection', async () => { - const connection = new MatrixRtcPeerConnection(session, sfuConfig); + const connection = new MatrixRtcPeerConnection( + livekitServiceUrl, + livekitUrl, + livekitToken, + getUserId, + ); const statisticsPromise = firstValueFrom( connection.observeStatistics().pipe(toArray()), @@ -74,16 +98,12 @@ describe('MatrixRtcPeerConnection', () => { let connection: MatrixRtcPeerConnection; beforeEach(() => { - connection = new MatrixRtcPeerConnection(session, sfuConfig); - - Object.defineProperty(connection, 'room', { - value: mockRoom, - writable: false, - }); - - mockRoom.eventEmitter.on(RoomEvent.DataReceived, (...args) => { - connection.handleDataReceived(args[0], args[1]); - }); + connection = new MatrixRtcPeerConnection( + livekitServiceUrl, + livekitUrl, + livekitToken, + getUserId, + ); }); afterEach(() => { @@ -103,124 +123,150 @@ describe('MatrixRtcPeerConnection', () => { const messagePromise = firstValueFrom(connection.observeMessages()); - const validMessageData = { + const message = { type: 'com.example.test', content: { key: 'value', nested: { prop: true } }, }; - const encodedData = new TextEncoder().encode( - JSON.stringify(validMessageData), - ); + const encodedData = new TextEncoder().encode(JSON.stringify(message)); - const mockParticipant = { - identity: '@remote-user-id:example.com:6jjRubIAWv', - sid: 'remote-session-id', + const participant: Partial = { + identity: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', }; - mockRoom.emitEvent(RoomEvent.DataReceived, encodedData, mockParticipant); + mockRoom.emitEvent(RoomEvent.DataReceived, encodedData, participant); await expect(messagePromise).resolves.toEqual({ + senderSessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + senderUserId: '@another-user-id:example.com', type: 'com.example.test', content: { key: 'value', nested: { prop: true } }, - senderSessionId: '_@remote-user-id:example.com_6jjRubIAWv', - senderUserId: '@remote-user-id:example.com', }); }); - it('should not receive message with invalid participant identity', async () => { + it('should ignore message if no session for participant identity', async () => { mockRoom.setConnectionState(ConnectionState.Connected); const messagePromise = firstValueFrom(connection.observeMessages()); - const validMessageData = { + const message = { type: 'com.example.test', content: { key: 'value', nested: { prop: true } }, }; - const encodedData = new TextEncoder().encode( - JSON.stringify(validMessageData), - ); + const encodedData = new TextEncoder().encode(JSON.stringify(message)); mockRoom.emitEvent(RoomEvent.DataReceived, encodedData, { - identity: '@remote-user-id', - sid: 'remote-session-id', + identity: 'unexpected-identity', }); mockRoom.emitEvent(RoomEvent.DataReceived, encodedData, { - identity: '@remote-user-id:example.com:6jjRubIAWv', - sid: 'remote-session-id', + identity: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', }); await expect(messagePromise).resolves.toEqual({ + senderSessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + senderUserId: '@another-user-id:example.com', type: 'com.example.test', content: { key: 'value', nested: { prop: true } }, - senderSessionId: '_@remote-user-id:example.com_6jjRubIAWv', - senderUserId: '@remote-user-id:example.com', }); }); - it('should ignore invalid payload JSON', async () => { + it('should ignore message if invalid', async () => { mockRoom.setConnectionState(ConnectionState.Connected); - const messagePromise = firstValueFrom( - connection.observeMessages().pipe(bufferTime(100)), - ); + const messagePromise = firstValueFrom(connection.observeMessages()); + + mockRoom.emitEvent(RoomEvent.DataReceived, 'invalid-data', { + identity: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + }); - const mockParticipant = { - identity: '@remote-user-id:example.com', - sid: 'remote-session-id', + const message = { + type: 'com.example.test', + content: { key: 'value', nested: { prop: true } }, }; - mockRoom.emitEvent( - RoomEvent.DataReceived, - 'invalid-data', - mockParticipant, - ); + const encodedData = new TextEncoder().encode(JSON.stringify(message)); - await expect(messagePromise).resolves.toEqual([]); + mockRoom.emitEvent(RoomEvent.DataReceived, encodedData, { + identity: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + }); + + await expect(messagePromise).resolves.toEqual({ + senderSessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + senderUserId: '@another-user-id:example.com', + type: 'com.example.test', + content: { key: 'value', nested: { prop: true } }, + }); }); it('should ignore invalid payload schema', async () => { mockRoom.setConnectionState(ConnectionState.Connected); - const messagePromise = firstValueFrom( - connection.observeMessages().pipe(bufferTime(100)), - ); + const messagePromise = firstValueFrom(connection.observeMessages()); + + mockRoom.emitEvent(RoomEvent.DataReceived, '{}', { + identity: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + }); - const mockParticipant = { - identity: '@remote-user-id:example.com', - sid: 'remote-session-id', + const message = { + type: 'com.example.test', + content: { key: 'value', nested: { prop: true } }, }; - mockRoom.emitEvent(RoomEvent.DataReceived, '{}', mockParticipant); + const encodedData = new TextEncoder().encode(JSON.stringify(message)); - await expect(messagePromise).resolves.toEqual([]); + mockRoom.emitEvent(RoomEvent.DataReceived, encodedData, { + identity: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + }); + + await expect(messagePromise).resolves.toEqual({ + senderSessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + senderUserId: '@another-user-id:example.com', + type: 'com.example.test', + content: { key: 'value', nested: { prop: true } }, + }); }); }); - describe('statistics and connection state', () => { + describe('statistics', () => { let connection: MatrixRtcPeerConnection; beforeEach(() => { - connection = new MatrixRtcPeerConnection(session, sfuConfig); + const identity = 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0'; + const participant: Partial = { + identity, + }; + remoteParticipants.set(identity, participant as RemoteParticipant); - Object.defineProperty(connection, 'room', { - value: mockRoom, - writable: false, - }); + mockRoom.emitEvent(RoomEvent.ParticipantConnected, participant); - mockRoom.eventEmitter.on(RoomEvent.ConnectionStateChanged, (...args) => { - connection.updateStatistics({ connectionState: args[0] }); - }); + connection = new MatrixRtcPeerConnection( + livekitServiceUrl, + livekitUrl, + livekitToken, + getUserId, + ); }); afterEach(() => { connection.close(); }); + it('should connect on creation and emit statistics with remote identity and participants', async () => { + const statisticsPromise = firstValueFrom(connection.observeStatistics()); + + await expect(statisticsPromise).resolves.toMatchObject({ + localParticipantIdentity: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', + remoteParticipantIdentities: [ + 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + ], + }); + }); + it('should handle connection state changes', async () => { const connectionStates: string[] = []; const subscription = connection.observeStatistics().subscribe((stats) => { - connectionStates.push(stats.connectionState as string); + connectionStates.push(stats.connectionState); }); mockRoom.setConnectionState(ConnectionState.Connected); @@ -236,6 +282,25 @@ describe('MatrixRtcPeerConnection', () => { subscription.unsubscribe(); }); + it('should handle remote participants changes', async () => { + const statisticsPromise = firstValueFrom(connection.observeStatistics()); + + const identity = 'J+T45tGruxc+HrUOqJJlyQSV33m728Cme4+vt8/SWrU'; + const participant: Partial = { + identity, + }; + remoteParticipants.set(identity, participant as RemoteParticipant); + + mockRoom.emitEvent(RoomEvent.ParticipantConnected, participant); + + await expect(statisticsPromise).resolves.toMatchObject({ + remoteParticipantIdentities: [ + 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + 'J+T45tGruxc+HrUOqJJlyQSV33m728Cme4+vt8/SWrU', + ], + }); + }); + it('should disconnect on peer close', async () => { const connectionStates: string[] = []; const subscription = connection.observeStatistics().subscribe((stats) => { diff --git a/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.ts b/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.ts index b79878ea4..fb3b337f8 100644 --- a/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.ts +++ b/packages/react-sdk/src/state/communication/connection/matrixRtcPeerConnection.ts @@ -31,48 +31,39 @@ import { switchMap, takeUntil, } from 'rxjs'; -import { Session } from '../discovery'; -import { SFUConfig } from '../matrixRtcCommunicationChannel'; import { Message, PeerConnection, PeerConnectionStatistics } from './types'; -import { - extractPeerConnectionStatistics, - parseLivekitJwtTokenIdentity, -} from './utils'; +import { extractPeerConnectionStatistics } from './utils'; export class MatrixRtcPeerConnection implements PeerConnection { - private readonly logger = getLogger('PeerConnection'); + private readonly logger = getLogger('MatrixRtcPeerConnection'); private readonly destroySubject = new Subject(); private readonly messageSubject = new Subject(); private readonly statisticsSubject = new Subject(); private readonly connectionStateSubject = new Subject(); + private readonly statistics: PeerConnectionStatistics; private encoder: TextEncoder = new TextEncoder(); private decoder: TextDecoder = new TextDecoder(); private readonly connectionId: string; - public readonly room = new Room(); + private readonly room = new Room(); constructor( - private readonly session: Session, - sfuConfig: SFUConfig, + private readonly livekitServiceUrl: string, + private readonly livekitUrl: string, + private readonly livekitToken: string, + private readonly getUserId: (sessionId: string) => string | undefined, ) { - this.connectionId = this.session.sessionId; + this.connectionId = livekitServiceUrl; this.logger.log(`Creating connection ${this.connectionId}`); // Statistics gathering this.statistics = { - remoteSessionId: session.sessionId, - remoteUserId: session.userId, - impolite: false, bytesReceived: 0, bytesSent: 0, packetsReceived: 0, packetsSent: 0, connectionState: ConnectionState.Disconnected, - signalingState: 'undefined', - iceConnectionState: 'undefined', - iceGatheringState: 'undefined', - dataChannelState: 'undefined', }; // In LiveKit, we have two peer connections, one for publishing and one for subscribing. @@ -105,11 +96,11 @@ export class MatrixRtcPeerConnection implements PeerConnection { } }); - this.initializeChannel(sfuConfig); + this.initializeChannel(); } getRemoteSessionId(): string { - return this.session.sessionId; + throw new Error('Unsupported, no remote session id in Matrix RTC'); } getConnectionId(): string { @@ -129,11 +120,19 @@ export class MatrixRtcPeerConnection implements PeerConnection { this.connectionStateSubject.complete(); } - sendMessage(type: string, content: T): void { + sendMessage( + type: string, + content: T, + { reliable }: { reliable?: boolean } = {}, + ): void { if (this.room.state === ConnectionState.Connected) { const message = { type, content }; - const data = this.encoder.encode(JSON.stringify(message)); - this.room.localParticipant.publishData(data); + const data = this.encoder.encode( + JSON.stringify(message), + ) as Uint8Array; + this.room.localParticipant.publishData(data, { + reliable, + }); } } @@ -157,18 +156,7 @@ export class MatrixRtcPeerConnection implements PeerConnection { } } - private async initializeChannel(sfuConfig: SFUConfig) { - this.room - .prepareConnection(sfuConfig.url, sfuConfig.jwt) - .then(() => this.room.connect(sfuConfig.url, sfuConfig.jwt)) - .catch((error) => { - this.logger.error(`Failed to initialize connection: ${error.message}`); - }); - - this.room.on(RoomEvent.DataReceived, (payload, participant) => { - this.handleDataReceived(payload, participant); - }); - + private async initializeChannel() { this.room.on(RoomEvent.ConnectionStateChanged, (state) => { this.logger.log( `Connection state of connection ${this.connectionId} changed: ${state}`, @@ -180,9 +168,32 @@ export class MatrixRtcPeerConnection implements PeerConnection { connectionState: state, }); }); + + this.room.on(RoomEvent.DataReceived, (payload, participant) => { + this.handleDataReceived(payload, participant); + }); + + this.room.on(RoomEvent.ParticipantConnected, () => { + this.handleParticipantsUpdated(this.room.remoteParticipants); + }); + + this.room.on(RoomEvent.ParticipantDisconnected, () => { + this.handleParticipantsUpdated(this.room.remoteParticipants); + }); + + await this.room.connect(this.livekitUrl, this.livekitToken); + + this.logger.log(`Connected ${this.connectionId}`); + + this.updateStatistics({ + localParticipantIdentity: this.room.localParticipant.identity, + remoteParticipantIdentities: getRemoteParticipantIdentities( + this.room.remoteParticipants, + ), + }); } - handleDataReceived( + private handleDataReceived( payload: Uint8Array, participant: RemoteParticipant | undefined, ): void { @@ -190,12 +201,10 @@ export class MatrixRtcPeerConnection implements PeerConnection { const message = JSON.parse(this.decoder.decode(payload)); if (typeof message.type === 'string' && message.content && participant) { - const { userId, deviceId } = parseLivekitJwtTokenIdentity( - participant.identity, - ); - if (userId && deviceId) { + const userId = this.getUserId(participant.identity); + if (userId) { this.messageSubject.next({ - senderSessionId: `_${userId}_${deviceId}`, + senderSessionId: participant.identity, senderUserId: userId, ...message, }); @@ -213,4 +222,19 @@ export class MatrixRtcPeerConnection implements PeerConnection { ); } } + + handleParticipantsUpdated( + remoteParticipants: Map, + ): void { + this.updateStatistics({ + remoteParticipantIdentities: + getRemoteParticipantIdentities(remoteParticipants), + }); + } +} + +function getRemoteParticipantIdentities( + remoteParticipants: Map, +): string[] { + return Array.from(remoteParticipants.keys()); } diff --git a/packages/react-sdk/src/state/communication/connection/types.ts b/packages/react-sdk/src/state/communication/connection/types.ts index 8a929ab6a..069940cd0 100644 --- a/packages/react-sdk/src/state/communication/connection/types.ts +++ b/packages/react-sdk/src/state/communication/connection/types.ts @@ -24,17 +24,19 @@ export type Message = { }; export type PeerConnectionStatistics = { - remoteUserId: string; - remoteSessionId: string; - impolite: boolean; + remoteUserId?: string; + remoteSessionId?: string; + localParticipantIdentity?: string; + remoteParticipantIdentities?: string[]; + impolite?: boolean; packetsReceived: number; bytesReceived: number; packetsSent: number; bytesSent: number; connectionState: string; - signalingState: string; - iceConnectionState: string; - iceGatheringState: string; + signalingState?: string; + iceConnectionState?: string; + iceGatheringState?: string; dataChannelState?: string; localCandidateType?: string; remoteCandidateType?: string; @@ -48,7 +50,11 @@ export type PeerConnection = { /** Close the connection. */ close(): void; /** Send a message with a type using the connection. */ - sendMessage(type: string, content: T): void; + sendMessage( + type: string, + content: T, + options?: MessageOptions, + ): void; /** * Returns an observable that contains all incoming messages on this * connection. @@ -64,3 +70,7 @@ export type PeerConnection = { */ observeConnectionState(): Observable; }; + +export type MessageOptions = { + reliable?: boolean; +}; diff --git a/packages/react-sdk/src/state/communication/connection/utils.test.ts b/packages/react-sdk/src/state/communication/connection/utils.test.ts index b20ec245e..cc04a7d8a 100644 --- a/packages/react-sdk/src/state/communication/connection/utils.test.ts +++ b/packages/react-sdk/src/state/communication/connection/utils.test.ts @@ -19,7 +19,6 @@ import { extractPeerConnectionStatistics, isImpolite, isPeerConnected, - parseLivekitJwtTokenIdentity, } from './utils'; describe('isImpolite', () => { @@ -190,36 +189,3 @@ describe('isPeerConnected', () => { ).toBe(false); }); }); - -describe('parseLivekitJwtTokenIdentity', () => { - it('should parse identity', () => { - expect( - parseLivekitJwtTokenIdentity('@user:example.com:6jjRubIAWv'), - ).toEqual({ - userId: '@user:example.com', - deviceId: '6jjRubIAWv', - }); - }); - - it.each(['@user:6jjRubIAWv', 'user:example.com:6jjRubIAWv'])( - 'should parse unexpected identity %s with wrong user id', - (identity) => { - expect(parseLivekitJwtTokenIdentity(identity)).toEqual({ - deviceId: '6jjRubIAWv', - }); - }, - ); - - it('should parse unexpected identity with wrong device id', () => { - expect(parseLivekitJwtTokenIdentity('@user:example.com:')).toEqual({ - userId: '@user:example.com', - }); - }); - - it.each(['something', ':', 'something::'])( - 'should parse unexpected identity', - (identity) => { - expect(parseLivekitJwtTokenIdentity(identity)).toEqual({}); - }, - ); -}); diff --git a/packages/react-sdk/src/state/communication/connection/utils.ts b/packages/react-sdk/src/state/communication/connection/utils.ts index e8b2eeb22..7905cd2be 100644 --- a/packages/react-sdk/src/state/communication/connection/utils.ts +++ b/packages/react-sdk/src/state/communication/connection/utils.ts @@ -75,30 +75,3 @@ export function isPeerConnected( dataChannelOpen && peerConnectionStatistics.connectionState === 'connected' ); } - -/** - * Parse livekit service jwt token identity that comes in the form matrixUserId:deviceId - */ -export function parseLivekitJwtTokenIdentity(identity: string): { - userId?: string; - deviceId?: string; -} { - const position = identity.lastIndexOf(':'); - if (position === -1) { - return {}; - } - - const userIdPart = identity.slice(0, position); - const deviceIdPart = identity.slice(position + 1); - - const userId = - userIdPart.startsWith('@') && userIdPart.includes(':') - ? userIdPart - : undefined; - const deviceId = deviceIdPart.length > 0 ? deviceIdPart : undefined; - - return { - userId, - deviceId, - }; -} diff --git a/packages/react-sdk/src/state/communication/discovery/autodiscovery.test.ts b/packages/react-sdk/src/state/communication/discovery/autodiscovery.test.ts deleted file mode 100644 index 6de78224a..000000000 --- a/packages/react-sdk/src/state/communication/discovery/autodiscovery.test.ts +++ /dev/null @@ -1,171 +0,0 @@ -/* - * Copyright 2025-2026 Nordeck IT + Consulting GmbH - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; -import { IOpenIDCredentials } from 'matrix-widget-api'; -import { afterEach, beforeEach, describe, expect, it, Mock, vi } from 'vitest'; -import AutoDiscovery from './autodiscovery'; -import { LivekitFocus } from './matrixRtcFocus'; - -describe('AutoDiscovery', () => { - let originalFetch: typeof globalThis.fetch; - let widgetApi: MockedWidgetApi; - let globalFetch: Mock; - - const activeFocus: LivekitFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit-jwt.example.org', - livekit_alias: '!room-id:example.com', - }; - - beforeEach(() => { - originalFetch = globalThis.fetch; - const mockFetch = vi.fn(); - AutoDiscovery.setFetchFn(mockFetch); - - widgetApi = mockWidgetApi(); - // @ts-ignore forcefully set for tests - widgetApi.widgetParameters.deviceId = 'DEVICEID'; - - // getLiveKitJWT uses the global fetch, not AutoDiscovery.fetch - globalFetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - json: () => - Promise.resolve({ url: 'wss://livekit.example.org', jwt: 'jwt' }), - }); - vi.stubGlobal('fetch', globalFetch); - }); - - afterEach(() => { - AutoDiscovery.setFetchFn(originalFetch); - widgetApi.stop(); - vi.unstubAllGlobals(); - vi.resetAllMocks(); - }); - - describe('getRawClientConfig', () => { - it('should handle .well-known 404 errors', async () => { - (AutoDiscovery['fetchFn'] as Mock).mockImplementation((url) => { - if (url === 'https://example.org/.well-known/matrix/client') { - return Promise.resolve({ - ok: false, - status: 404, - }); - } - return Promise.reject(new Error('Unexpected URL')); - }); - - const result = await AutoDiscovery.getRawClientConfig('example.org'); - expect(result).toEqual({}); - }); - - it('should handle network errors', async () => { - (AutoDiscovery['fetchFn'] as Mock).mockImplementation(() => { - return Promise.reject(new Error('Network error')); - }); - - const result = await AutoDiscovery.getRawClientConfig('example.org'); - expect(result).toEqual({}); - }); - - it('should throw error for invalid domain', async () => { - await expect(AutoDiscovery.getRawClientConfig('')).rejects.toThrow( - "'domain' must be a string of non-zero length", - ); - }); - - it('should fetch raw client config', async () => { - const mockWellKnown = { - 'm.homeserver': { base_url: 'https://matrix.example.org' }, - }; - - (AutoDiscovery['fetchFn'] as Mock).mockImplementation(() => { - return Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(mockWellKnown), - }); - }); - - const result = await AutoDiscovery.getRawClientConfig('example.org'); - expect(result).toEqual(mockWellKnown); - }); - }); - - describe('getLiveKitJWT', () => { - it('should only forward the credential fields of the OpenID token', async () => { - // The widget API resolves the raw response data, which also carries - // request metadata that the LiveKit JWT Token Service rejects. - const openIdToken = { - access_token: 'access-token', - expires_in: 3600, - matrix_server_name: 'example.com', - token_type: 'Bearer', - state: 'allowed', - original_request_id: 'request-id', - } as IOpenIDCredentials; - - await AutoDiscovery.getLiveKitJWT( - widgetApi, - 'https://livekit-jwt.example.org', - '!room-id:example.com', - openIdToken, - ); - - const body = JSON.parse(globalFetch.mock.calls[0][1].body); - expect(body.openid_token).toEqual({ - access_token: 'access-token', - expires_in: 3600, - matrix_server_name: 'example.com', - token_type: 'Bearer', - }); - }); - }); - - describe('getSFUConfigWithOpenID', () => { - it('should exchange the OpenID token of the widget API for an SFU config', async () => { - widgetApi.requestOpenIDConnectToken.mockResolvedValue({ - access_token: 'access-token', - expires_in: 3600, - matrix_server_name: 'example.com', - token_type: 'Bearer', - // metadata added by the widget API that must not be forwarded - state: 'allowed', - original_request_id: 'request-id', - } as IOpenIDCredentials); - - await expect( - AutoDiscovery.getSFUConfigWithOpenID(widgetApi, activeFocus), - ).resolves.toEqual({ url: 'wss://livekit.example.org', jwt: 'jwt' }); - - expect(globalFetch).toHaveBeenCalledWith( - 'https://livekit-jwt.example.org/sfu/get', - expect.anything(), - ); - expect(JSON.parse(globalFetch.mock.calls[0][1].body)).toEqual({ - room: '!room-id:example.com', - openid_token: { - access_token: 'access-token', - expires_in: 3600, - matrix_server_name: 'example.com', - token_type: 'Bearer', - }, - device_id: 'DEVICEID', - }); - }); - }); -}); diff --git a/packages/react-sdk/src/state/communication/discovery/autodiscovery.ts b/packages/react-sdk/src/state/communication/discovery/autodiscovery.ts index b3b93dbb6..6b86f42f3 100644 --- a/packages/react-sdk/src/state/communication/discovery/autodiscovery.ts +++ b/packages/react-sdk/src/state/communication/discovery/autodiscovery.ts @@ -1,7 +1,7 @@ /* * Copyright 2018 New Vector Ltd * Copyright 2019 The Matrix.org Foundation C.I.C. - * Copyright 2025-2026 Nordeck IT + Consulting GmbH + * Copyright 2025 Nordeck IT + Consulting GmbH * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,10 +20,8 @@ // copyright notices // eslint-disable-next-line notice/notice -import { WidgetApi } from '@matrix-widget-toolkit/api'; import { getLogger } from 'loglevel'; import { IOpenIDCredentials } from 'matrix-widget-api'; -import { SFUConfig } from '../matrixRtcCommunicationChannel'; import { LivekitFocus } from './matrixRtcFocus'; export interface IWellKnownConfig { @@ -40,6 +38,11 @@ export interface IClientWellKnown { export const FOCI_WK_KEY = 'org.matrix.msc4143.rtc_foci'; +export interface SFUConfig { + url: string; + jwt: string; +} + /* * This AutoDiscovery class is inspired on the matrix-js-sdk one * see: https://github.com/matrix-org/matrix-js-sdk/blob/develop/src/autodiscovery.ts @@ -126,25 +129,23 @@ export default class AutoDiscovery { * @returns */ public static async getSFUConfigWithOpenID( - widgetApi: WidgetApi, + openIDToken: IOpenIDCredentials, activeFocus: LivekitFocus, + slotId: string, + userId: string, + deviceId: string, + memberId: string, ): Promise { - const openIdToken = await widgetApi.requestOpenIDConnectToken(); - - AutoDiscovery.logger.debug('Got openID token', openIdToken); - try { - AutoDiscovery.logger.info( - `Trying to get JWT from call's active focus URL of ${activeFocus.livekit_service_url}...`, - ); const sfuConfig = await AutoDiscovery.getLiveKitJWT( - widgetApi, activeFocus.livekit_service_url, activeFocus.livekit_alias, - openIdToken, + openIDToken, + slotId, + userId, + deviceId, + memberId, ); - AutoDiscovery.logger.info(`Got JWT from call's active focus URL.`); - return sfuConfig; } catch (e) { AutoDiscovery.logger.warn( @@ -165,29 +166,29 @@ export default class AutoDiscovery { * @returns */ public static async getLiveKitJWT( - widgetApi: WidgetApi, livekitServiceURL: string, roomName: string, openIDToken: IOpenIDCredentials, + slotId: string, + userId: string, + deviceId: string, + memberId: string, ): Promise { try { - const res = await fetch(livekitServiceURL + '/sfu/get', { + const res = await fetch(livekitServiceURL + '/get_token', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ - room: roomName, - // Only forward the credential fields. The Widget API result carries - // identity request permissions metadata such as `state` that the - // LiveKit JWT Token Service rejects. - openid_token: { - access_token: openIDToken.access_token, - expires_in: openIDToken.expires_in, - matrix_server_name: openIDToken.matrix_server_name, - token_type: openIDToken.token_type, + room_id: roomName, + slot_id: slotId, + openid_token: openIDToken, + member: { + id: memberId, + claimed_user_id: userId, + claimed_device_id: deviceId, }, - device_id: widgetApi.widgetParameters.deviceId, }), }); if (!res.ok) { diff --git a/packages/react-sdk/src/state/communication/discovery/index.ts b/packages/react-sdk/src/state/communication/discovery/index.ts index 682abbc19..31c4d8bab 100644 --- a/packages/react-sdk/src/state/communication/discovery/index.ts +++ b/packages/react-sdk/src/state/communication/discovery/index.ts @@ -15,6 +15,7 @@ */ export { MatrixRtcSessionManagerImpl } from './matrixRtcSessionManagerImpl'; +export type { MatrixRtcSession } from './matrixRtcSessionManagerImpl'; export type { LivekitFocus, @@ -23,7 +24,7 @@ export type { RTCFocus, } from './matrixRtcFocus'; -export { isLivekitFocusConfig } from './matrixRtcFocus'; +export { getActiveFocus, isLivekitFocusConfig } from './matrixRtcFocus'; export { SessionManagerImpl } from './sessionManagerImpl'; export type { Session, SessionManager } from './types'; diff --git a/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.test.ts b/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.test.ts index aeb9f49dd..498d086d6 100644 --- a/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.test.ts +++ b/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.test.ts @@ -14,15 +14,11 @@ * limitations under the License. */ -import { getEnvironment } from '@matrix-widget-toolkit/mui'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import AutoDiscovery from './autodiscovery'; import { getWellKnownFoci, - isEqualFocus, - isLivekitFocusActive, isLivekitFocusConfig, - makeFociPreferred, RTCFocus, } from './matrixRtcFocus'; @@ -93,130 +89,6 @@ describe('matrixRtcFocus', () => { }); }); - describe('makeFociPreferred', () => { - const memberFocus: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://member.livekit.example.com', - livekit_alias: '!room-id:example.com', - }; - const wellKnownFoci: RTCFocus[] = [ - { - type: 'livekit', - livekit_service_url: 'https://wellknown1.livekit.example.com', - }, - { - type: 'livekit', - livekit_service_url: 'https://wellknown2.livekit.example.com', - }, - ]; - - beforeEach(() => { - vi.mocked(getEnvironment).mockImplementation( - (_, defaultValue) => defaultValue, - ); - }); - - it('should return only member focus when no well-known foci and no env var', () => { - const result = makeFociPreferred(memberFocus, []); - - expect(result).toEqual([memberFocus]); - }); - - it('should return only well-known foci when no member focus and no env var', () => { - const result = makeFociPreferred(undefined, wellKnownFoci); - - expect(result).toEqual([ - { - type: 'livekit', - livekit_service_url: 'https://wellknown1.livekit.example.com', - }, - { - type: 'livekit', - livekit_service_url: 'https://wellknown2.livekit.example.com', - }, - ]); - }); - - it('should return member focus first, then well-known foci', () => { - const result = makeFociPreferred(memberFocus, wellKnownFoci); - - expect(result).toEqual([ - memberFocus, - { - type: 'livekit', - livekit_service_url: 'https://wellknown1.livekit.example.com', - }, - { - type: 'livekit', - livekit_service_url: 'https://wellknown2.livekit.example.com', - }, - ]); - }); - - it('should add environment variable focus when provided', () => { - const envServiceUrl = 'https://env.livekit.example.com'; - vi.mocked(getEnvironment).mockImplementation((_value) => { - return envServiceUrl; - }); - - const result = makeFociPreferred(memberFocus, wellKnownFoci); - - expect(result).toEqual([ - memberFocus, - { - type: 'livekit', - livekit_service_url: 'https://wellknown1.livekit.example.com', - }, - { - type: 'livekit', - livekit_service_url: 'https://wellknown2.livekit.example.com', - }, - { - type: 'livekit', - livekit_service_url: envServiceUrl, - }, - ]); - }); - - it('should remove duplicate foci', () => { - const envServiceUrl = 'https://wellknown1.livekit.example.com'; - vi.mocked(getEnvironment).mockImplementation((_value) => { - return envServiceUrl; - }); - - const duplicateWellKnownFoci: RTCFocus[] = [ - { - type: 'livekit', - livekit_service_url: 'https://wellknown1.livekit.example.com', // same as the env foci - }, - { - type: 'livekit', - livekit_service_url: 'https://member.livekit.example.com', // same as the member focus - }, - ]; - - const result = makeFociPreferred(memberFocus, duplicateWellKnownFoci); - - expect(result).toEqual([ - { - type: 'livekit', - livekit_service_url: 'https://member.livekit.example.com', - livekit_alias: '!room-id:example.com', - }, - { - type: 'livekit', - livekit_service_url: 'https://wellknown1.livekit.example.com', - }, - ]); - }); - - it('should handle empty inputs', () => { - const result = makeFociPreferred(undefined, []); - - expect(result).toEqual([]); - }); - }); - describe('isLivekitFocusConfig', () => { it('should return true for valid LivekitFocusConfig objects', () => { const validConfig: RTCFocus = { @@ -253,99 +125,4 @@ describe('matrixRtcFocus', () => { expect(isLivekitFocusConfig(extraProps)).toBe(true); }); }); - - describe('isEqualFocus', () => { - it('should return true for identical objects', () => { - const focus1: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }; - - const focus2: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }; - - expect(isEqualFocus(focus1, focus2)).toBe(true); - }); - - it('should return true for identical objects with additional properties', () => { - const focus1: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - livekit_alias: '!room-alias', - }; - - const focus2: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }; - - expect(isEqualFocus(focus1, focus2)).toBe(true); - }); - - it('should return false for different types', () => { - const focus1: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }; - - const focus2: RTCFocus = { - type: 'full_mesh', - }; - - expect(isEqualFocus(focus1, focus2)).toBe(false); - }); - - it('should return false for different URLs', () => { - const focus1: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }; - - const focus2: RTCFocus = { - type: 'livekit', - livekit_service_url: 'https://livekit.example.org', - }; - - expect(isEqualFocus(focus1, focus2)).toBe(false); - }); - }); - - describe('isLivekitFocusActive', () => { - it('should return true for valid LivekitFocusActive objects', () => { - const validActive: RTCFocus = { - type: 'livekit', - focus_selection: 'oldest_membership', - }; - - expect(isLivekitFocusActive(validActive)).toBe(true); - }); - - it('should return false for non-livekit type', () => { - const invalidType: RTCFocus = { - type: 'full_mesh', - }; - - expect(isLivekitFocusActive(invalidType)).toBe(false); - }); - - it('should return false when focus_selection is missing', () => { - const missingSelection: RTCFocus = { - type: 'livekit', - }; - - expect(isLivekitFocusActive(missingSelection)).toBe(false); - }); - - it('should return true when additional properties are present', () => { - const extraProps: RTCFocus = { - type: 'livekit', - focus_selection: 'oldest_membership', - additional_property: 'value', - }; - - expect(isLivekitFocusActive(extraProps)).toBe(true); - }); - }); }); diff --git a/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.ts b/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.ts index 028c1cff7..9ced8fa64 100644 --- a/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.ts +++ b/packages/react-sdk/src/state/communication/discovery/matrixRtcFocus.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -import { getEnvironment } from '@matrix-widget-toolkit/mui'; -import uniqWith from 'lodash/uniqWith'; +import first from 'lodash/first'; import { getLogger } from 'loglevel'; import AutoDiscovery, { FOCI_WK_KEY } from './autodiscovery'; @@ -66,74 +65,24 @@ export async function getWellKnownFoci( return foci; } -// As defined in MSC4143 -// https://github.com/matrix-org/matrix-spec-proposals/blob/toger5/matrixRTC/proposals/4143-matrix-rtc.md#choosing-the-value-of-foci_preferred-for-the-mrtcmember-state-event -export function makeFociPreferred( - memberFocus: RTCFocus | undefined, - wellKnownFoci: RTCFocus[], -): RTCFocus[] { - const logger = getLogger('matrixRtcFocus.makeFociPreferred'); +export const isLivekitFocusConfig = ( + object: RTCFocus, +): object is LivekitFocusConfig => + object.type === 'livekit' && 'livekit_service_url' in object; - const preferredFoci: RTCFocus[] = []; +export function getActiveFocus(fociPreferred: RTCFocus[]): LivekitFocusConfig { + const firstFociPreferred = first(fociPreferred); - if (memberFocus) { - logger.debug('Adding member focus to preferred foci', memberFocus); - preferredFoci.push(memberFocus); - } else { - logger.debug('No member focus provided, skipping'); + if (firstFociPreferred === undefined) { + throw new Error('Foci preferred must be not empty'); } - if (wellKnownFoci.length > 0) { - logger.debug('Adding .well-known foci to preferred foci'); - preferredFoci.push(...wellKnownFoci); - } else { - logger.debug('No .well-known preferred foci provided, skipping'); + if (!isLivekitFocusConfig(firstFociPreferred)) { + throw new Error('Foci preferred must contain a livekit focus config'); } - // check for specific livekit backend config underride - const envFoci = getEnvironment('REACT_APP_RTC_LIVEKIT_SERVICE_URL'); - if (envFoci) { - logger.debug( - 'Adding environment variable for LiveKit service URL', - envFoci, - ); - const livekit_config: LivekitFocusConfig = { - type: 'livekit', - livekit_service_url: envFoci, - }; - preferredFoci.push(livekit_config); - } - - const foci = uniqWith(preferredFoci, isEqualFocus); - - logger.debug('Final preferred foci:', foci); - return foci; + return { + type: 'livekit', + livekit_service_url: firstFociPreferred.livekit_service_url, + }; } - -export function isEqualFocus( - value: RTCFocus | undefined, - other: RTCFocus | undefined, -): boolean { - let isEqual = false; - - if (!value || !other) { - return false; - } - - if (value.type === 'livekit' && other.type === 'livekit') { - isEqual = value.livekit_service_url === other.livekit_service_url; - } else { - isEqual = value.type === other.type; - } - return isEqual; -} - -export const isLivekitFocusConfig = ( - object: RTCFocus, -): object is LivekitFocusConfig => - object.type === 'livekit' && 'livekit_service_url' in object; - -export const isLivekitFocusActive = ( - focus_active: RTCFocus, -): focus_active is LivekitFocusActive => - focus_active.type === 'livekit' && 'focus_selection' in focus_active; diff --git a/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.test.ts b/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.test.ts index ee1d8c4cc..6db98ebce 100644 --- a/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.test.ts +++ b/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.test.ts @@ -15,22 +15,40 @@ */ import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; +import { nanoid } from '@reduxjs/toolkit'; import { UpdateDelayedEventAction } from 'matrix-widget-api'; import { firstValueFrom, take, toArray } from 'rxjs'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { mockWhiteboardMembership } from '../../../lib/testUtils/matrixRtcMock'; -import { STATE_EVENT_RTC_MEMBER } from '../../../model'; import { - DEFAULT_RTC_EXPIRE_DURATION, - RTC_APPLICATION_WHITEBOARD, - RTCSessionEventContent, -} from '../../../model/matrixRtcSessions'; + mockRtcMember, + mockRtcMemberJoinContent, + mockRtcMemberLeaveContent, +} from '../../../lib/testUtils/matrixTestUtils'; +import { ROOM_EVENT_4143_RTC_MEMBER } from '../../../model'; import * as matrixRtcFocus from './matrixRtcFocus'; import { MatrixRtcSessionManagerImpl } from './matrixRtcSessionManagerImpl'; +vi.mock('@reduxjs/toolkit', async () => ({ + ...(await vi.importActual( + '@reduxjs/toolkit', + )), + nanoid: vi.fn(), +})); + +beforeEach(() => { + let count = -1; + vi.mocked(nanoid).mockImplementation(() => { + count++; + if (count === 0) return 'memberA'; + else if (count === 1) return 'memberB'; + else throw new Error('unexpected'); + }); +}); + describe('MatrixRtcSessionManagerImpl', () => { const removeSessionDelay = 8000; let widgetApi: MockedWidgetApi; + let rtcSessionManager: MatrixRtcSessionManagerImpl; beforeEach(() => { @@ -40,15 +58,19 @@ describe('MatrixRtcSessionManagerImpl', () => { // @ts-ignore forcefully set for tests widgetApi.widgetParameters.userId = '@user-id:example.com'; // @ts-ignore forcefully set for tests - widgetApi.widgetParameters.deviceId = 'DEVICEID'; + widgetApi.widgetParameters.deviceId = 'DEVICE1'; // @ts-ignore forcefully set for tests widgetApi.widgetParameters.roomId = '!room-id:example.com'; - vi.stubEnv('REACT_APP_RTC', 'matrixrtc'); + widgetApi.getRtcTransports.mockResolvedValue([ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ]); rtcSessionManager = new MatrixRtcSessionManagerImpl( widgetApi, - DEFAULT_RTC_EXPIRE_DURATION, removeSessionDelay, ); @@ -56,9 +78,12 @@ describe('MatrixRtcSessionManagerImpl', () => { vi.spyOn(matrixRtcFocus, 'getWellKnownFoci').mockResolvedValue([ { type: 'livekit', - livekit_service_url: 'https://livekit.example.com', + livekit_service_url: 'https://livekit-jwt.example.com', }, ]); + + // Sets system time for RTC member events to be sticky according to duration + vi.setSystemTime(0); }); afterEach(() => { @@ -69,97 +94,205 @@ describe('MatrixRtcSessionManagerImpl', () => { }); it('should join a whiteboard', async () => { - const { sessionId } = await rtcSessionManager.join('whiteboard-id'); + const joinedPromise = firstValueFrom( + rtcSessionManager.observeSessionJoined().pipe(take(1), toArray()), + ); - expect(sessionId).toEqual(expect.any(String)); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, + await expect(rtcSessionManager.join('whiteboard-id')).resolves.toEqual({ + userId: '@user-id:example.com', + sessionId: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', + memberId: 'memberA', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }); + + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'join', + device_id: 'DEVICE1', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: 'memberA', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, + expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, + { + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'delayed_leave', + }, + msc4354_sticky_key: 'memberA', + }, removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); expect(rtcSessionManager.getRemoveSessionDelayId()).toEqual( - 'syd_bcooaGNyKtyFbIGjGMQR', + 'syd_wlGAStYmBRRdjnWiHSDA', ); - expect(rtcSessionManager.getSessions()).toEqual([ + await expect(joinedPromise).resolves.toEqual([ { - sessionId: sessionId, + sessionId: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', userId: '@user-id:example.com', + memberId: 'memberA', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, }, ]); }); - it('should join another whiteboard', async () => { - await rtcSessionManager.join('whiteboard-id-0'); + it('should leave a whiteboard', async () => { + const joinedPromise = firstValueFrom( + rtcSessionManager.observeSessionJoined().pipe(take(1), toArray()), + ); + + await rtcSessionManager.join('whiteboard-id'); + + await expect(joinedPromise).resolves.toEqual([ + { + sessionId: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', + userId: '@user-id:example.com', + memberId: 'memberA', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }, + ]); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, + const leftPromise = firstValueFrom( + rtcSessionManager.observeSessionLeft().pipe(take(1), toArray()), + ); + + await rtcSessionManager.leave(); + + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id-0', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'leave', + }, + msc4354_sticky_key: 'memberA', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( + 'syd_wlGAStYmBRRdjnWiHSDA', + UpdateDelayedEventAction.Cancel, ); + await expect(leftPromise).resolves.toEqual([ + { + sessionId: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', + userId: '@user-id:example.com', + memberId: 'memberA', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }, + ]); + }); - widgetApi.sendDelayedStateEvent.mockResolvedValue({ + it('should join another whiteboard', async () => { + await rtcSessionManager.join('whiteboard-id-0'); + + widgetApi.sendDelayedRoomEvent.mockResolvedValue({ delay_id: 'qqq_bcooaGNyKtyFbIGjGMQR', }); await rtcSessionManager.join('whiteboard-id-1'); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - { stateKey: '_@user-id:example.com_DEVICEID' }, + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, + { + slot_id: 'net.nordeck.whiteboard#whiteboard-id-0', + member: { + id: 'memberA', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'leave', + }, + msc4354_sticky_key: 'memberA', + }, + { stickyDurationMs: 3600000 }, ); expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', + 'syd_wlGAStYmBRRdjnWiHSDA', UpdateDelayedEventAction.Cancel, ); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id-1', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + slot_id: 'net.nordeck.whiteboard#whiteboard-id-1', + member: { + id: 'memberB', + membership: 'join', + device_id: 'DEVICE1', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id-1', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: 'memberB', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenNthCalledWith( - 2, - STATE_EVENT_RTC_MEMBER, - {}, + expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, + { + slot_id: 'net.nordeck.whiteboard#whiteboard-id-1', + member: { + id: 'memberB', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'delayed_leave', + }, + msc4354_sticky_key: 'memberB', + }, removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); expect(rtcSessionManager.getRemoveSessionDelayId()).toEqual( 'qqq_bcooaGNyKtyFbIGjGMQR', @@ -167,186 +300,243 @@ describe('MatrixRtcSessionManagerImpl', () => { }); it('should join the same whiteboard', async () => { - const { sessionId } = await rtcSessionManager.join('whiteboard-id'); - - expect(sessionId).toEqual(expect.any(String)); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', - }, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); + await rtcSessionManager.join('whiteboard-id'); widgetApi.sendDelayedStateEvent.mockResolvedValue({ - delay_id: 'qqq_bcooaGNyKtyFbIGjGMQR', + delay_id: 'syd_wlGAStYmBRRdjnWiHSDA', }); await rtcSessionManager.join('whiteboard-id'); - expect(widgetApi.sendStateEvent).toHaveBeenNthCalledWith( + expect(widgetApi.sendRoomEvent).toHaveBeenNthCalledWith( 2, - STATE_EVENT_RTC_MEMBER, - {}, - { stateKey: '_@user-id:example.com_DEVICEID' }, + ROOM_EVENT_4143_RTC_MEMBER, + { + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'leave', + }, + msc4354_sticky_key: 'memberA', + }, + { stickyDurationMs: 3600000 }, ); expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', + 'syd_wlGAStYmBRRdjnWiHSDA', UpdateDelayedEventAction.Cancel, ); - expect(widgetApi.sendStateEvent).toHaveBeenNthCalledWith( + expect(widgetApi.sendRoomEvent).toHaveBeenNthCalledWith( 3, - STATE_EVENT_RTC_MEMBER, + ROOM_EVENT_4143_RTC_MEMBER, { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberB', + membership: 'join', + device_id: 'DEVICE1', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: 'memberB', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenNthCalledWith( + expect(widgetApi.sendDelayedRoomEvent).toHaveBeenNthCalledWith( 2, - STATE_EVENT_RTC_MEMBER, - {}, + ROOM_EVENT_4143_RTC_MEMBER, + { + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberB', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'delayed_leave', + }, + msc4354_sticky_key: 'memberB', + }, removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); expect(rtcSessionManager.getRemoveSessionDelayId()).toEqual( - 'qqq_bcooaGNyKtyFbIGjGMQR', + 'syd_wlGAStYmBRRdjnWiHSDA', ); }); - it('should create a new membership when joining from a new device', async () => { + it('should update membership if sticky event is about to become not sticky', async () => { + const joinedPromise = firstValueFrom( + rtcSessionManager.observeSessionJoined().pipe(take(1), toArray()), + ); + await rtcSessionManager.join('whiteboard-id'); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'join', + device_id: 'DEVICE1', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: 'memberA', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - - // @ts-ignore forcefully set for tests - widgetApi.widgetParameters.deviceId = 'OTHERDEVICEID'; - const otherRtcSessionManager = new MatrixRtcSessionManagerImpl(widgetApi); - await otherRtcSessionManager.join('whiteboard-id'); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, + await expect(joinedPromise).resolves.toEqual([ { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'OTHERDEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + sessionId: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', + userId: '@user-id:example.com', + memberId: 'memberA', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, }, - { stateKey: '_@user-id:example.com_OTHERDEVICEID' }, - ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenNthCalledWith( - 2, - STATE_EVENT_RTC_MEMBER, - {}, - removeSessionDelay, - { stateKey: '_@user-id:example.com_OTHERDEVICEID' }, - ); - expect(rtcSessionManager.getRemoveSessionDelayId()).toEqual( - 'syd_bcooaGNyKtyFbIGjGMQR', + ]); + + expect(widgetApi.updateDelayedEvent).not.toHaveBeenCalledWith( + 'syd_wlGAStYmBRRdjnWiHSDA', + UpdateDelayedEventAction.Cancel, ); - }); - it('should update membership if it is about to expire', async () => { - const expectedExpires = Date.now() + DEFAULT_RTC_EXPIRE_DURATION; - await rtcSessionManager.join('whiteboard-id'); + widgetApi.sendDelayedRoomEvent.mockResolvedValue({ + delay_id: 'qqq_bcooaGNyKtyFbIGjGMQR', + }); - expect(widgetApi.sendStateEvent).toHaveBeenNthCalledWith( - 1, - STATE_EVENT_RTC_MEMBER, + vi.advanceTimersByTime(3600000 * 0.99); + + await vi.waitFor(() => + expect(widgetApi.sendRoomEvent).toHaveBeenCalledTimes(2), + ); + + expect(widgetApi.sendRoomEvent).toHaveBeenNthCalledWith( + 2, + ROOM_EVENT_4143_RTC_MEMBER, { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expectedExpires, - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'join', + device_id: 'DEVICE1', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: 'memberA', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, + ); + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( + 'syd_wlGAStYmBRRdjnWiHSDA', + UpdateDelayedEventAction.Cancel, ); - vi.advanceTimersByTime(DEFAULT_RTC_EXPIRE_DURATION * 0.8); + vi.advanceTimersByTime(3600000 * 0.99); await vi.waitFor(() => - expect(widgetApi.sendStateEvent).toHaveBeenCalledTimes(2), + expect(widgetApi.sendRoomEvent).toHaveBeenCalledTimes(3), ); - expect(widgetApi.sendStateEvent).toHaveBeenNthCalledWith( - 2, - STATE_EVENT_RTC_MEMBER, + expect(widgetApi.sendRoomEvent).toHaveBeenNthCalledWith( + 3, + ROOM_EVENT_4143_RTC_MEMBER, { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'join', + device_id: 'DEVICE1', + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: 'whiteboard-id', + }, + transports: { + published: [ + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + }, + ], + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: 'memberA', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, + ); + expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( + 'qqq_bcooaGNyKtyFbIGjGMQR', + UpdateDelayedEventAction.Cancel, ); - - // Check separately that the expires value is greater than expected - const lastCall = widgetApi.sendStateEvent.mock - .calls[1][1] as RTCSessionEventContent; - expect(lastCall.expires).toBeGreaterThan(expectedExpires); }); - it('should restart a remove membership delayed event if it is about to be sent', async () => { + it('should restart a remove membership delayed sticky event if it is about to be sent', async () => { await rtcSessionManager.join('whiteboard-id'); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, + expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, + { + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'delayed_leave', + }, + msc4354_sticky_key: 'memberA', + }, removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); expect(widgetApi.updateDelayedEvent).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75 + 100); expect(widgetApi.updateDelayedEvent).toHaveBeenLastCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', + 'syd_wlGAStYmBRRdjnWiHSDA', UpdateDelayedEventAction.Restart, ); expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(1); @@ -354,29 +544,40 @@ describe('MatrixRtcSessionManagerImpl', () => { await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75); expect(widgetApi.updateDelayedEvent).toHaveBeenLastCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', + 'syd_wlGAStYmBRRdjnWiHSDA', UpdateDelayedEventAction.Restart, ); expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(2); }); - it('should stop to restart a remove membership delayed event if restart failed', async () => { + it('should stop to restart a remove membership delayed sticky event if restart failed', async () => { widgetApi.updateDelayedEvent.mockRejectedValue(new Error('Some error')); await rtcSessionManager.join('whiteboard-id'); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, + expect(widgetApi.sendDelayedRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_4143_RTC_MEMBER, + { + slot_id: 'net.nordeck.whiteboard#whiteboard-id', + member: { + id: 'memberA', + membership: 'leave', + device_id: 'DEVICE1', + }, + leave_reason: { + code: 'delayed_leave', + }, + msc4354_sticky_key: 'memberA', + }, removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, + { stickyDurationMs: 3600000 }, ); expect(widgetApi.updateDelayedEvent).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75 + 100); expect(widgetApi.updateDelayedEvent).toHaveBeenLastCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', + 'syd_wlGAStYmBRRdjnWiHSDA', UpdateDelayedEventAction.Restart, ); expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(1); @@ -386,618 +587,171 @@ describe('MatrixRtcSessionManagerImpl', () => { expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(1); }); - it('should handle new members joining a whiteboard', async () => { - await rtcSessionManager.join('whiteboard-id'); - - const joinedPromise = firstValueFrom( - rtcSessionManager.observeSessionJoined().pipe(take(1), toArray()), + it('should handle joined members from history', async () => { + // Send a leave event first and then join event + widgetApi.mockSendRoomEvent( + mockRtcMember({ + sender: '@another-user:example.com', + content: mockRtcMemberLeaveContent(), + }), ); - widgetApi.mockSendStateEvent( - mockWhiteboardMembership({ - content: { - call_id: 'whiteboard-id', - scope: 'm.room', - application: RTC_APPLICATION_WHITEBOARD, - device_id: 'ANOTHERDEVICEID', - expires: 123456789, - foci_preferred: [], - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - }, - state_key: '_@another-user_ANOTHERDEVICEID', + widgetApi.mockSendRoomEvent( + mockRtcMember({ sender: '@another-user:example.com', }), ); - await expect(joinedPromise).resolves.toEqual([ - { - sessionId: '_@another-user_ANOTHERDEVICEID', - userId: '@another-user:example.com', - }, - ]); - }); - - it('should handle members leaving a whiteboard', async () => { - const leftPromise = firstValueFrom( - rtcSessionManager.observeSessionLeft().pipe(take(1), toArray()), + // No leave event, a joined user + widgetApi.mockSendRoomEvent( + mockRtcMember({ + sender: '@another-user-id:example.com', + content: mockRtcMemberJoinContent({ + deviceId: 'DEVICE2', + memberId: 'memberB', + }), + }), ); - await rtcSessionManager.join('whiteboard-id'); - - widgetApi.mockSendStateEvent( - mockWhiteboardMembership( - { - state_key: '_@another-user_ANOTHERDEVICEID', - content: {}, - }, - true, - ), + const joinedPromise = firstValueFrom( + rtcSessionManager.observeSessionJoined().pipe(take(1), toArray()), ); - await expect(leftPromise).resolves.toEqual([ - { - sessionId: '_@another-user_ANOTHERDEVICEID', - userId: '@user-id:example.com', - }, - ]); - }); - - it('should update membership when own membership is removed by user himself', async () => { await rtcSessionManager.join('whiteboard-id'); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, + await expect(joinedPromise).resolves.toEqual([ { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', - }, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledTimes(1); - - widgetApi.mockSendStateEvent( - mockWhiteboardMembership( - { - state_key: '_@user-id:example.com_DEVICEID', - content: {}, + sessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + userId: '@another-user-id:example.com', + memberId: 'memberB', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', }, - true, - ), - ); - - await vi.waitFor(() => - expect(widgetApi.sendStateEvent).toHaveBeenNthCalledWith( - 2, - STATE_EVENT_RTC_MEMBER, - { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - scope: 'm.room', - }, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ), - ); - - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledTimes(1); - }); - - it('should update membership and reschedule remove membership delayed event when membership is removed by another user', async () => { - await rtcSessionManager.join('whiteboard-id'); - - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { type: 'livekit', focus_selection: 'oldest_membership' }, - scope: 'm.room', }, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - - widgetApi.sendDelayedStateEvent.mockResolvedValue({ - delay_id: 'qqq_bcooaGNyKtyFbIGjGMQR', - }); - widgetApi.updateDelayedEvent.mockImplementation((delayId) => { - if (delayId === 'syd_bcooaGNyKtyFbIGjGMQR') { - // Throw an error if a previous delay_id is passed - throw new Error('Cannot find a delayed event'); - } else { - return Promise.resolve(); - } - }); - - // Use another user to send empty member event, according to MSC4140 this will cancel a more recent state event. - widgetApi.mockSendStateEvent( - mockWhiteboardMembership( - { - state_key: '_@user-id:example.com_DEVICEID', - content: {}, - sender: '@another-user-id:example.com', - }, - true, - ), - ); - - await vi.waitFor(() => - expect(widgetApi.sendStateEvent).toHaveBeenNthCalledWith( - 2, - STATE_EVENT_RTC_MEMBER, - { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - scope: 'm.room', - }, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ), - ); - - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75 + 100); - - // This refresh request will fail and get cancelled - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', - UpdateDelayedEventAction.Restart, - ); - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( - 'qqq_bcooaGNyKtyFbIGjGMQR', - UpdateDelayedEventAction.Restart, - ); - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(2); - expect(rtcSessionManager.getRemoveSessionDelayId()).toEqual( - 'qqq_bcooaGNyKtyFbIGjGMQR', - ); - - await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75); - - expect(widgetApi.updateDelayedEvent).toHaveBeenLastCalledWith( - 'qqq_bcooaGNyKtyFbIGjGMQR', - UpdateDelayedEventAction.Restart, - ); - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(3); + ]); }); - it('should update membership and reschedule remove membership delayed event when failed to restart membership delayed event and own membership is removed', async () => { - widgetApi.updateDelayedEvent.mockRejectedValue(new Error('Some error')); - - await rtcSessionManager.join('whiteboard-id'); - - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - removeSessionDelay, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - expect(widgetApi.updateDelayedEvent).not.toHaveBeenCalled(); - expect(rtcSessionManager.getRemoveSessionDelayId()).toEqual( - 'syd_bcooaGNyKtyFbIGjGMQR', - ); - - await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75 + 100); - - expect(widgetApi.updateDelayedEvent).toHaveBeenLastCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', - UpdateDelayedEventAction.Restart, - ); - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(1); - expect(rtcSessionManager.getRemoveSessionDelayId()).toBeUndefined(); - - await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75); - - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(1); - - widgetApi.updateDelayedEvent.mockResolvedValue(undefined); - widgetApi.sendDelayedStateEvent.mockResolvedValue({ - delay_id: 'qqq_bcooaGNyKtyFbIGjGMQR', - }); - - widgetApi.mockSendStateEvent( - mockWhiteboardMembership( - { - state_key: '_@user-id:example.com_DEVICEID', - content: {}, - }, - true, - ), - ); - - await vi.waitFor(() => - expect(widgetApi.sendStateEvent).toHaveBeenNthCalledWith( - 2, - STATE_EVENT_RTC_MEMBER, - { - application: RTC_APPLICATION_WHITEBOARD, - call_id: 'whiteboard-id', - device_id: 'DEVICEID', - expires: expect.any(Number), - foci_preferred: expect.any(Array), - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - scope: 'm.room', - }, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ), - ); - - expect(widgetApi.sendDelayedStateEvent).toHaveBeenCalledTimes(2); - expect(rtcSessionManager.getRemoveSessionDelayId()).toEqual( - 'qqq_bcooaGNyKtyFbIGjGMQR', - ); - - await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75 + 100); - - expect(widgetApi.updateDelayedEvent).toHaveBeenLastCalledWith( - 'qqq_bcooaGNyKtyFbIGjGMQR', - UpdateDelayedEventAction.Restart, - ); - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(2); - - await vi.advanceTimersByTimeAsync(removeSessionDelay * 0.75); - - expect(widgetApi.updateDelayedEvent).toHaveBeenLastCalledWith( - 'qqq_bcooaGNyKtyFbIGjGMQR', - UpdateDelayedEventAction.Restart, + it('should handle new members joining a whiteboard', async () => { + const joinedPromise = firstValueFrom( + rtcSessionManager.observeSessionJoined().pipe(take(2), toArray()), ); - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledTimes(3); - }); - it('should remove membership when leaving', async () => { await rtcSessionManager.join('whiteboard-id'); - const leftPromise = firstValueFrom( - rtcSessionManager.observeSessionLeft().pipe(take(1), toArray()), + widgetApi.mockSendRoomEvent( + mockRtcMember({ + sender: '@another-user-id:example.com', + content: mockRtcMemberJoinContent({ + deviceId: 'DEVICE2', + memberId: 'memberB', + }), + }), ); - await rtcSessionManager.leave(); - - await expect(leftPromise).resolves.toEqual([ + await expect(joinedPromise).resolves.toEqual([ { - sessionId: '_@user-id:example.com_DEVICEID', + sessionId: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', userId: '@user-id:example.com', + memberId: 'memberA', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }, + { + sessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + userId: '@another-user-id:example.com', + memberId: 'memberB', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, }, ]); - - expect(rtcSessionManager.getSessions()).toEqual([]); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - {}, - { stateKey: '_@user-id:example.com_DEVICEID' }, - ); - expect(widgetApi.updateDelayedEvent).toHaveBeenCalledWith( - 'syd_bcooaGNyKtyFbIGjGMQR', - UpdateDelayedEventAction.Cancel, - ); - expect(rtcSessionManager.getRemoveSessionDelayId()).toBeUndefined(); }); - it('should close observables', async () => { + it('should handle members leaving a whiteboard', async () => { const joinedPromise = firstValueFrom( - rtcSessionManager.observeSessionJoined().pipe(toArray()), + rtcSessionManager.observeSessionJoined().pipe(take(2), toArray()), ); + const leftPromise = firstValueFrom( - rtcSessionManager.observeSessionLeft().pipe(toArray()), + rtcSessionManager.observeSessionLeft().pipe(take(1), toArray()), ); - rtcSessionManager.destroy(); - - await expect(joinedPromise).resolves.toEqual([]); - await expect(leftPromise).resolves.toEqual([]); - }); - - it('should set membership with preferred foci when joining a session', async () => { - rtcSessionManager.initFociDiscovery(); - - await firstValueFrom(rtcSessionManager.observeActiveFocus()); - await rtcSessionManager.join('whiteboard-id'); - expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( - STATE_EVENT_RTC_MEMBER, - expect.objectContaining({ - foci_preferred: [ - { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - livekit_alias: '!room-id:example.com', - }, - ], + widgetApi.mockSendRoomEvent( + mockRtcMember({ + sender: '@another-user-id:example.com', + content: mockRtcMemberJoinContent({ + deviceId: 'DEVICE2', + memberId: 'memberB', + }), }), - expect.any(Object), ); - }); - - it('should re-join and update session with new foci if they change', async () => { - rtcSessionManager.initFociDiscovery(); - - await rtcSessionManager.join('whiteboard-id'); - widgetApi.sendStateEvent.mockClear(); - - // Simulate a foci change - vi.spyOn(matrixRtcFocus, 'getWellKnownFoci').mockResolvedValue([ + await expect(joinedPromise).resolves.toEqual([ { - type: 'livekit', - livekit_service_url: 'https://new-livekit.example.com', + sessionId: 'vtdiVgWoeLb2NR7dph94uv/R4+U6uQTmCYE9Q0BlgUw', + userId: '@user-id:example.com', + memberId: 'memberA', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, }, - ]); - - // default well known foci polling is 60 seconds - vi.advanceTimersByTime(60 * 1000 + 500); - - const activeFocusPromise = firstValueFrom( - rtcSessionManager.observeActiveFocus().pipe(take(1)), - ); - - // Verify the focus update was emitted - await expect(activeFocusPromise).resolves.toEqual({ - type: 'livekit', - livekit_service_url: 'https://new-livekit.example.com', - }); - - // Verify the active focus was updated - expect(rtcSessionManager.getActiveFocus()).toEqual({ - type: 'livekit', - livekit_service_url: 'https://new-livekit.example.com', - }); - - // re-join the session to trigger the foci update - await rtcSessionManager.join('whiteboard-id'); - - expect(widgetApi.sendStateEvent).toHaveBeenCalledTimes(2); - - const firstCall = widgetApi.sendStateEvent.mock.calls[0]; - // expect content to be empty for the first call - expect(firstCall[1]).toEqual({}); - - const secondCall = widgetApi.sendStateEvent.mock.calls[1]; - // expect content to contain the new foci for the second call - expect(secondCall[1]).toEqual( - expect.objectContaining({ - foci_preferred: [ - { - type: 'livekit', - livekit_service_url: 'https://new-livekit.example.com', - livekit_alias: '!room-id:example.com', - }, - ], - }), - ); - }); - - it('should not trigger focus update if the active focus is the same', async () => { - rtcSessionManager.initFociDiscovery(); - - await rtcSessionManager.join('whiteboard-id'); - - // Simulate a foci change - vi.spyOn(matrixRtcFocus, 'getWellKnownFoci').mockResolvedValue([ { - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', + sessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + userId: '@another-user-id:example.com', + memberId: 'memberB', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, }, ]); - // default well known foci polling is 60 seconds - vi.advanceTimersByTime(60 * 1000 + 500); - - // Verify that no focus update was done - expect(rtcSessionManager.getActiveFocus()).toEqual({ - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }); - }); - - it('should use member focus before joining ongoing session', async () => { - rtcSessionManager.initFociDiscovery(); - - widgetApi.mockSendStateEvent( - mockWhiteboardMembership({ - content: { - call_id: 'whiteboard-id', - scope: 'm.room', - application: RTC_APPLICATION_WHITEBOARD, - device_id: 'FIRSTDEVICEID', - expires: Date.now() + DEFAULT_RTC_EXPIRE_DURATION, - foci_preferred: [ - { - type: 'livekit', - livekit_service_url: 'https://active-livekit.example.com', - livekit_alias: '!room-id:example.com', - }, - ], - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - }, - state_key: '_@first-user_FIRSTDEVICEID', - sender: '@first-user:example.com', + // Unexpected user, no session + widgetApi.mockSendRoomEvent( + mockRtcMember({ + sender: '@unknown-user-id:example.com', + content: mockRtcMemberLeaveContent({ + memberId: 'memberY', + deviceId: 'DEVICE0', + }), }), ); - - await firstValueFrom(rtcSessionManager.observeActiveFocus()); - - expect(rtcSessionManager.getActiveFocus()).toEqual({ - type: 'livekit', - livekit_service_url: 'https://active-livekit.example.com', - livekit_alias: '!room-id:example.com', - }); - }); - - it('should change active focus when oldest member leaves', async () => { - rtcSessionManager.initFociDiscovery(); - - widgetApi.mockSendStateEvent( - mockWhiteboardMembership({ - content: { - call_id: 'whiteboard-id', - scope: 'm.room', - application: RTC_APPLICATION_WHITEBOARD, - device_id: 'FIRSTDEVICEID', - expires: Date.now() + DEFAULT_RTC_EXPIRE_DURATION, - foci_preferred: [ - { - type: 'livekit', - livekit_service_url: 'https://active-livekit.example.com', - livekit_alias: '!room-id:example.com', - }, - ], - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - }, - state_key: '_@first-user_FIRSTDEVICEID', - sender: '@first-user:example.com', + // A joined user leaves + widgetApi.mockSendRoomEvent( + mockRtcMember({ + sender: '@another-user-id:example.com', + content: mockRtcMemberLeaveContent({ + memberId: 'memberB', + deviceId: 'DEVICE2', + }), }), ); - await rtcSessionManager.join('whiteboard-id'); - - // simulate first user leaving - widgetApi.mockSendStateEvent({ - content: {}, - state_key: '_@first-user_FIRSTDEVICEID', - sender: '@first-user:example.com', - type: STATE_EVENT_RTC_MEMBER, - event_id: '$event-id-0', - room_id: '!room-id:example.com', - origin_server_ts: 0, - }); - - await firstValueFrom(rtcSessionManager.observeActiveFocus()); - - // Verify the active focus was updated - expect(rtcSessionManager.getActiveFocus()).toEqual({ - type: 'livekit', - livekit_service_url: 'https://livekit.example.com', - }); + await expect(leftPromise).resolves.toEqual([ + { + sessionId: 'sBDICto7qQGve1yoevllcuYOnxSoJ9wpRP6XJ8e1rB0', + userId: '@another-user-id:example.com', + memberId: 'memberB', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }, + ]); }); - it('should not change active focus when some member leaves', async () => { - rtcSessionManager.initFociDiscovery(); - - // setup oldest membership with active focus - widgetApi.mockSendStateEvent( - mockWhiteboardMembership({ - content: { - call_id: 'whiteboard-id', - scope: 'm.room', - application: RTC_APPLICATION_WHITEBOARD, - device_id: 'FIRSTDEVICEID', - expires: Date.now() + DEFAULT_RTC_EXPIRE_DURATION, - foci_preferred: [ - { - type: 'livekit', - livekit_service_url: 'https://oldest-livekit.example.com', - livekit_alias: '!room-id:example.com', - }, - ], - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - }, - state_key: '_@first-user_FIRSTDEVICEID', - sender: '@first-user:example.com', - }), + it('should close observables', async () => { + const joinedPromise = firstValueFrom( + rtcSessionManager.observeSessionJoined().pipe(toArray()), ); - - await rtcSessionManager.join('whiteboard-id'); - - expect(rtcSessionManager.getActiveFocus()).toEqual({ - type: 'livekit', - livekit_service_url: 'https://oldest-livekit.example.com', - livekit_alias: '!room-id:example.com', - }); - - widgetApi.mockSendStateEvent( - mockWhiteboardMembership({ - content: { - call_id: 'whiteboard-id', - scope: 'm.room', - application: RTC_APPLICATION_WHITEBOARD, - device_id: 'ANOTHERDEVICEID', - expires: Date.now() + DEFAULT_RTC_EXPIRE_DURATION, - foci_preferred: [ - { - type: 'livekit', - livekit_service_url: 'https://other-livekit.example.com', - livekit_alias: '!room-id:example.com', - }, - ], - focus_active: { - type: 'livekit', - focus_selection: 'oldest_membership', - }, - }, - state_key: '_@other-user_ANOTHERDEVICEID', - sender: '@other-user:example.com', - }), + const leftPromise = firstValueFrom( + rtcSessionManager.observeSessionLeft().pipe(toArray()), ); - vi.advanceTimersByTime(60 * 1000 + 500); - - // simulate the latest user leaving - widgetApi.mockSendStateEvent({ - content: {}, - state_key: '_@other-user_ANOTHERDEVICEID', - sender: '@other-user:example.com', - type: STATE_EVENT_RTC_MEMBER, - event_id: '$event-id-0', - room_id: '!room-id:example.com', - origin_server_ts: 0, - }); - - // used as a way to sync the session manager state - await rtcSessionManager.leave(); + rtcSessionManager.destroy(); - expect(rtcSessionManager.getActiveFocus()).toEqual({ - type: 'livekit', - livekit_service_url: 'https://oldest-livekit.example.com', - livekit_alias: '!room-id:example.com', - }); + await expect(joinedPromise).resolves.toEqual([]); + await expect(leftPromise).resolves.toEqual([]); }); }); diff --git a/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.ts b/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.ts index a637daf09..13c326de4 100644 --- a/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.ts +++ b/packages/react-sdk/src/state/communication/discovery/matrixRtcSessionManagerImpl.ts @@ -14,9 +14,12 @@ * limitations under the License. */ -import { StateEvent, WidgetApi } from '@matrix-widget-toolkit/api'; -import clone from 'lodash/clone'; -import isEqual from 'lodash/isEqual'; +import { + RoomEvent, + WidgetApi, + isRoomEventCurrentlySticky, +} from '@matrix-widget-toolkit/api'; +import { nanoid } from '@reduxjs/toolkit'; import isError from 'lodash/isError'; import { getLogger } from 'loglevel'; import { UpdateDelayedEventAction } from 'matrix-widget-api'; @@ -28,77 +31,81 @@ import { interval, switchMap, takeUntil, + timer, } from 'rxjs'; import { - RTCSessionEventContent, - STATE_EVENT_RTC_MEMBER, - isRTCSessionNotExpired, - newRTCSession, + ROOM_EVENT_4143_RTC_MEMBER, + RtcMember, + RtcMemberJoin, + RtcMemberLeave, + Transport, + isLivekitTransport, + isRtcMemberJoinEvent, + isRtcMemberLeaveEvent, + isValidWhiteboardRtcMemberEvent, } from '../../../model'; -import { - DEFAULT_RTC_EXPIRE_DURATION, - isWhiteboardRTCSessionStateEvent, -} from '../../../model/matrixRtcSessions'; -import { - RTCFocus, - getWellKnownFoci, - makeFociPreferred, -} from './matrixRtcFocus'; -import { SessionState } from './sessionManagerImpl'; -import { MatrixRtcSessionManager, Session } from './types'; - -export class MatrixRtcSessionManagerImpl implements MatrixRtcSessionManager { - private readonly logger = getLogger('RTCSessionManager'); + +import { matrixRtcParticipantIdentity } from '../../../lib'; +import { SessionManager } from './types'; + +export type MatrixRtcSession = { + userId: string; + sessionId: string; + memberId: string; + livekitTransport: { livekitServiceUrl: string }; +}; + +export class MatrixRtcSessionManagerImpl implements SessionManager { + private readonly logger = getLogger('MatrixRtcSessionManager'); private readonly destroySubject = new Subject(); private readonly leaveSubject = new Subject(); - private readonly sessionJoinedSubject = new Subject(); - private readonly sessionLeftSubject = new Subject(); - private readonly activeFocusSubject = new Subject(); - private readonly sessionSubject = new Subject(); - private sessions: StateEvent[] = []; - private joinState: { whiteboardId: string; sessionId: string } | undefined; - private fociPreferred: RTCFocus[] = []; - private wellKnownFoci: RTCFocus[] = []; - private activeFocus: RTCFocus | undefined; + private readonly sessionJoinedSubject = new Subject(); + private readonly sessionLeftSubject = new Subject(); + private readonly stickyDurationMs: number = 3600000; + + private sessions: MatrixRtcSession[] = []; + private joinState: + | { + whiteboardId: string; + sessionId: string; + userId: string; + deviceId: string; + memberId: string; + } + | undefined; /** * Holds remove membership event delay id. * Is undefined is homeserver doesn't support delayed events. - * Is assigned undefined if cannot refresh a delayed event with this id. + * Is assigned undefined if cannot restart a delayed event with this id. */ private removeSessionDelayId?: string; constructor( private readonly widgetApiPromise: Promise | WidgetApi, - private readonly sessionTimeout = DEFAULT_RTC_EXPIRE_DURATION, - private readonly wellKnownPollingInterval = 60 * 1000, private readonly removeSessionDelay: number = 8000, ) {} - initFociDiscovery(): void { - this.checkForWellKnownFoci().catch((error) => { - this.logger.error('Failed to check for well-known foci:', error); - }); - - interval(this.wellKnownPollingInterval) - .pipe(takeUntil(this.destroySubject)) - .subscribe(async () => { - await this.checkForWellKnownFoci(); - }); - } - getSessionId(): string | undefined { return this.joinState?.sessionId; } - getSessions(): Session[] { - return this.sessions.map(({ state_key, sender }) => ({ - sessionId: state_key, - userId: sender, - })); - } - - getActiveFocus(): RTCFocus | undefined { - return this.activeFocus; + /** Gets a list of all active sessions, including the own session. */ + getSessions(): MatrixRtcSession[] { + return this.sessions.map( + ({ + userId, + sessionId, + memberId, + livekitTransport: { livekitServiceUrl }, + }) => ({ + userId, + sessionId, + memberId, + livekitTransport: { + livekitServiceUrl, + }, + }), + ); } /** @@ -108,23 +115,24 @@ export class MatrixRtcSessionManagerImpl implements MatrixRtcSessionManager { return this.removeSessionDelayId; } - observeSessionJoined(): Observable { + /** + * Observes new sessions that joined the current whiteboard. + * Is also triggered for the own session. + */ + observeSessionJoined(): Observable { return this.sessionJoinedSubject; } - observeSessionLeft(): Observable { + /** + * Observes sessions that left the current whiteboard, like expired + * sessions. + * Is also triggered for the own session. + */ + observeSessionLeft(): Observable { return this.sessionLeftSubject; } - observeActiveFocus(): Observable { - return this.activeFocusSubject; - } - - observeSession(): Observable { - return this.sessionSubject; - } - - async join(whiteboardId: string): Promise<{ sessionId: string }> { + async join(whiteboardId: string): Promise { if (this.joinState) { this.logger.debug('Already joined a whiteboard, must leave first.'); await this.leave(); @@ -132,126 +140,170 @@ export class MatrixRtcSessionManagerImpl implements MatrixRtcSessionManager { const widgetApi = await this.widgetApiPromise; const { userId, deviceId } = widgetApi.widgetParameters; - const sessionId = `_${userId}_${deviceId}`; - this.logger.debug( - `Joining whiteboard ${whiteboardId} as session ${sessionId}`, - ); + if (!userId || !deviceId) { + throw new Error('Unknown user id or device id '); + } - interval(this.sessionTimeout * 0.75) - .pipe( - takeUntil(this.destroySubject), - takeUntil(this.leaveSubject), - switchMap(() => this.refreshOwnSession(sessionId, whiteboardId)), - ) - .subscribe(); + const memberId = nanoid(); + const sessionId = await matrixRtcParticipantIdentity( + userId, + deviceId, + memberId, + ); - await this.refreshOwnSession(sessionId, whiteboardId); - await this.scheduleRemoveMembershipDelayedEvent(widgetApi, sessionId); + this.logger.debug( + `Joining whiteboard ${whiteboardId} as session ${sessionId}, member ${memberId}`, + ); - // Handle session events + const leftSet = new Set(); + let ownJoinEventReceived: boolean = false; from(Promise.resolve(this.widgetApiPromise)) .pipe( switchMap((widgetApi) => - widgetApi.observeStateEvents(STATE_EVENT_RTC_MEMBER), + widgetApi.observeRoomEvents(ROOM_EVENT_4143_RTC_MEMBER), ), - filter(isWhiteboardRTCSessionStateEvent), + filter(isRoomEventCurrentlySticky), + filter(isValidWhiteboardRtcMemberEvent), + filter((event) => { + if (ownJoinEventReceived) { + // do not filter events + return true; + } + + // filter out join events that are left until we get a join event for this session + const { id: eventMemberId, membership } = event.content.member; + if (membership === 'leave') { + leftSet.add(eventMemberId); + return true; + } else { + if (eventMemberId === memberId) { + leftSet.clear(); + ownJoinEventReceived = true; + return true; + } + + return !leftSet.has(eventMemberId); + } + }), takeUntil(this.destroySubject), takeUntil(this.leaveSubject), ) - .subscribe(async (rtcSession) => { - if ( - Object.keys(rtcSession.content).length === 0 && - rtcSession.state_key === sessionId - ) { - // refresh a membership event when event for this session is removed - await this.refreshOwnSession(sessionId, whiteboardId); - - if (!this.removeSessionDelayId || rtcSession.sender !== userId) { - /** - * Re-schedule if delay id is empty (failed to refresh a delayed event) - * - * Re-schedule if a remove membership event is sent by another user. - * In this case a delayed state event is cancelled according to MSC4140. - */ - await this.scheduleRemoveMembershipDelayedEvent( - widgetApi, - sessionId, - ); - } - } else { - this.handleRTCSessionEvent(rtcSession); - } + .subscribe(async (rtcMemberEvent) => { + await this.handleRtcMemberEvent(rtcMemberEvent); }); - this.observeSessionLeft() - .pipe(takeUntil(this.destroySubject), takeUntil(this.leaveSubject)) - .subscribe(async () => { - await this.computeActiveFocus(); - }); + const transports: Transport[] = await widgetApi.getRtcTransports(); + const livekitTransport = getLivekitTransport(transports); - this.joinState = { sessionId, whiteboardId }; + await this.sendRtcMemberJoinEvent(memberId, whiteboardId, transports); + await this.sendRtcMemberLeaveDelayedEvent( + widgetApi, + memberId, + deviceId, + whiteboardId, + ); + await this.scheduleRestartRtcMemberLeaveDelayedEvent(widgetApi, memberId); - return { sessionId }; + this.joinState = { sessionId, whiteboardId, userId, deviceId, memberId }; + + return { sessionId, userId, memberId, livekitTransport }; } /** * Sends a remove membership delayed event, updates delay id. - * Refreshes a delayed event periodically. - * Invalidates a delay id if failed to refresh. * @param widgetApi Widget API - * @param sessionId session id + * @param memberId member id + * @param deviceId device id + * @param whiteboardId whiteboard id */ - private async scheduleRemoveMembershipDelayedEvent( + private async sendRtcMemberLeaveDelayedEvent( widgetApi: WidgetApi, - sessionId: string, + memberId: string, + deviceId: string, + whiteboardId: string, ): Promise { + this.logger.debug( + `Sending RTC member leave delayed event for memberId: ${memberId}`, + ); + let removeSessionDelayId: string | undefined; + const rtcMemberLeave: RtcMemberLeave = { + slot_id: `net.nordeck.whiteboard#${whiteboardId}`, + member: { + id: memberId, + membership: 'leave', + device_id: deviceId, + }, + leave_reason: { + code: 'delayed_leave', + }, + msc4354_sticky_key: memberId, + }; try { ({ delay_id: removeSessionDelayId } = - await widgetApi.sendDelayedStateEvent( - STATE_EVENT_RTC_MEMBER, - {}, + await widgetApi.sendDelayedRoomEvent( + ROOM_EVENT_4143_RTC_MEMBER, + rtcMemberLeave, this.removeSessionDelay, - { - stateKey: sessionId, - }, + { stickyDurationMs: this.stickyDurationMs }, )); + + this.logger.debug( + `Sent RTC member leave delayed event for memberId: ${memberId}, removeSessionDelayId: ${removeSessionDelayId}`, + ); + + this.removeSessionDelayId = removeSessionDelayId; } catch (ex) { this.logger.error( 'Could not send remove membership delayed event:', isError(ex) ? ex.message : ex, ); } + } - if (removeSessionDelayId) { + /** + * Restarts a delayed event periodically. + * Invalidates a delay id if failed to restart. + * @param widgetApi Widget API + * @param memberId member id + */ + private async scheduleRestartRtcMemberLeaveDelayedEvent( + widgetApi: WidgetApi, + memberId: string, + ): Promise { + this.logger.debug( + `Scheduling RTC member leave delayed event restart for memberId: ${memberId}`, + ); + + if (this.removeSessionDelayId) { interval(this.removeSessionDelay * 0.75) .pipe( takeUntil(this.destroySubject), takeUntil(this.leaveSubject), - switchMap(() => - widgetApi.updateDelayedEvent( - removeSessionDelayId, - UpdateDelayedEventAction.Restart, - ), - ), + switchMap(() => { + this.logger.debug( + `Restarting membership leave delayed event: ${this.removeSessionDelayId}`, + ); + if (this.removeSessionDelayId) { + return widgetApi.updateDelayedEvent( + this.removeSessionDelayId, + UpdateDelayedEventAction.Restart, + ); + } else { + return Promise.resolve(); + } + }), ) .subscribe({ error: (err) => { - if ( - this.removeSessionDelayId && - this.removeSessionDelayId === removeSessionDelayId - ) { - this.removeSessionDelayId = undefined; - } this.logger.error( - 'Could not refresh delayed event:', + `Could not restart delayed event: ${this.removeSessionDelayId}, error:`, isError(err) ? err.message : err, ); + this.removeSessionDelayId = undefined; }, }); - - this.removeSessionDelayId = removeSessionDelayId; } } @@ -259,7 +311,8 @@ export class MatrixRtcSessionManagerImpl implements MatrixRtcSessionManager { if (!this.joinState) { return; } - const { sessionId, whiteboardId } = this.joinState; + const { sessionId, whiteboardId, userId, deviceId, memberId } = + this.joinState; this.joinState = undefined; this.leaveSubject.next(); @@ -268,16 +321,17 @@ export class MatrixRtcSessionManagerImpl implements MatrixRtcSessionManager { `Leaving whiteboard ${whiteboardId} as session ${sessionId}`, ); - const widgetApi = await this.widgetApiPromise; - const { userId } = widgetApi.widgetParameters; + this.removeSession(sessionId, userId, memberId); - if (userId) { - this.removeSession(sessionId, userId); - } - - await this.endRtcSession(sessionId); + await this.sendRtcMemberLeaveEvent( + userId, + deviceId, + memberId, + whiteboardId, + ); if (this.removeSessionDelayId) { - widgetApi.updateDelayedEvent( + const widgetApi = await this.widgetApiPromise; + await widgetApi.updateDelayedEvent( this.removeSessionDelayId, UpdateDelayedEventAction.Cancel, ); @@ -286,264 +340,258 @@ export class MatrixRtcSessionManagerImpl implements MatrixRtcSessionManager { } destroy(): void { + this.logger.log(`Destroy session manager`); + this.destroySubject.next(); this.sessionJoinedSubject.complete(); - this.sessionSubject.complete(); this.sessionLeftSubject.complete(); - this.activeFocusSubject.complete(); } - private async checkForWellKnownFoci(): Promise { - this.logger.debug('Looking up the homeserver RTC foci'); + private async handleRtcMemberEvent( + event: RoomEvent, + ): Promise { + const { + sender, + content: { + member: { id: memberId, device_id: deviceId }, + }, + } = event; + + const sessionId = await matrixRtcParticipantIdentity( + sender, + deviceId, + memberId, + ); - // Check for foci in .well-known/matrix/client - const widgetApi = await this.widgetApiPromise; - const domain = widgetApi.widgetParameters.userId?.replace(/^.*?:/, ''); - - const foci = await getWellKnownFoci(domain); - this.logger.debug('Found homeserver foci', JSON.stringify(foci)); - - // There was a change in the homeserver's foci - if (!isEqual(foci, this.wellKnownFoci)) { - this.logger.debug('Homeserver foci changed'); - this.wellKnownFoci = foci; - await this.computeActiveFocus(); - } else { - this.logger.debug('No new homeserver foci found'); + if (event.content.member.membership === 'join') { + this.logger.debug( + 'Handling RTC join event', + event.event_id, + event.sender, + sessionId, + new Date(event.origin_server_ts).toISOString(), + ); } - } - private async computeActiveFocus() { - this.logger.debug('Checking if a new active focus is required'); + if (isRtcMemberLeaveEvent(event)) { + if (sessionId === this.joinState?.sessionId) { + this.logger.log(`Leaving session ${sessionId}`); + // Reset the join state + this.joinState = undefined; + } - const memberFocus = await this.selectMemberFocus(); + this.removeSession(sessionId, event.sender, memberId); + return; + } else if (isRtcMemberJoinEvent(event)) { + if (sessionId === this.joinState?.sessionId) { + const { + content: { + application: { whiteboard_id: whiteboardId }, + transports: { published: transports }, + }, + } = event; - this.fociPreferred = makeFociPreferred(memberFocus, this.wellKnownFoci); + timer(this.stickyDurationMs * 0.9) + .pipe(takeUntil(this.destroySubject), takeUntil(this.leaveSubject)) + .subscribe(async () => { + this.logger.log(`Updating RTC member for memberId: ${memberId}`); - // Check for a new active foci to change to - const newActiveFocus = this.fociPreferred[0]; - if (!isEqual(this.activeFocus, newActiveFocus)) { - this.logger.debug('New active focus:', newActiveFocus); - this.activeFocus = newActiveFocus; - this.activeFocusSubject.next(newActiveFocus); - } - } + await this.sendRtcMemberJoinEvent( + memberId, + whiteboardId, + transports, + ); - private async selectMemberFocus(): Promise { - const widgetApi = await this.widgetApiPromise; - let sessions: StateEvent[] = []; + const widgetApi = await this.widgetApiPromise; - if (!this.joinState) { - this.logger.debug( - 'Not joined yet, need to retrieve session member state events', - ); - try { - sessions = await widgetApi.receiveStateEvents(STATE_EVENT_RTC_MEMBER); - } catch (error) { - this.logger.error( - 'Failed to receive session member state events', - error, - ); - return; - } - } else { - this.logger.debug( - 'Already joined, using cached session member state events', - ); - sessions = this.sessions; - } + if (this.removeSessionDelayId) { + this.logger.log( + `Cancelling RTC removeSessionDelayId: ${this.removeSessionDelayId}`, + ); + await widgetApi.updateDelayedEvent( + this.removeSessionDelayId, + UpdateDelayedEventAction.Cancel, + ); + this.removeSessionDelayId = undefined; + } - // Filter out invalid and expired sessions - sessions = sessions - .filter(isWhiteboardRTCSessionStateEvent) - .filter(isRTCSessionNotExpired); + await this.sendRtcMemberLeaveDelayedEvent( + widgetApi, + memberId, + deviceId, + whiteboardId, + ); + }); + } - if (sessions.length < 1) { - this.logger.debug('No member focus to check, skipping'); + this.addSession(sessionId, memberId, event); return; } - - // sort the sessions by expire time - const sortedSessions = sessions.sort((a, b) => { - const aExpire = a.content.expires || Infinity; - const bExpire = b.content.expires || Infinity; - return aExpire - bExpire; - }); - - // get the oldest session (smaller expires) preferred focus - const oldestSession = sortedSessions[0]; - this.logger.debug('Found oldest session:', oldestSession.state_key); - if (oldestSession && oldestSession.state_key) { - // check for active focus selection type - if ( - oldestSession.content.focus_active.type === 'livekit' && - oldestSession.content.focus_active.focus_selection === - 'oldest_membership' - ) { - // if this is the oldest session, we don't care about member focus - if (oldestSession.state_key === this.getSessionId()) { - return undefined; - } else { - const newMemberFocus = oldestSession.content.foci_preferred[0]; - this.logger.debug('New member focus:', newMemberFocus); - return newMemberFocus; - } - } else { - this.logger.error( - 'Unsupported focus selection type on oldest session member', - ); - } - } } - private handleRTCSessionEvent( - event: StateEvent, + private addSession( + sessionId: string, + memberId: string, + event: RoomEvent, ): void { - const sessionId = event.state_key; - const whiteboardId = event.content.call_id; + const { + sender, + content: { + application: { whiteboard_id }, + transports, + }, + } = event; + this.logger.debug( + `Session ${sessionId} by ${sender} joined whiteboard ${whiteboard_id} event`, + event, + 'member', + memberId, + ); - this.logger.debug('Handling RTC event', JSON.stringify(event)); + const livekitTransport = getLivekitTransport(transports.published); - this.sessionSubject.next({ + const session: MatrixRtcSession = { + userId: sender, sessionId, - userId: event.sender, - expiresTs: event.content.expires, - whiteboardId, - }); - - if (Object.keys(event.content).length === 0) { - this.removeSession(sessionId, event.sender); - return; - } - - this.sessions = this.sessions.filter(isRTCSessionNotExpired); + memberId, + livekitTransport, + }; + this.sessions = [...this.sessions, session]; - const existingSessionIndex = this.sessions.findIndex( - (s) => s.state_key === sessionId && s.content.call_id === whiteboardId, + this.logger.debug( + 'Sessions updated', + JSON.stringify(this.sessions), + 'length', + this.sessions.length, ); - if (existingSessionIndex >= 0) { - this.sessions[existingSessionIndex] = event; - } else { - if (sessionId !== this.getSessionId()) { - this.addSession(event); - } else { - this.sessions.push(event); - } - } - - this.logger.debug('Sessions updated', JSON.stringify(this.sessions)); + this.sessionJoinedSubject.next({ + sessionId, + userId: sender, + memberId, + livekitTransport, + }); } - private addSession(session: StateEvent): void { - const { sender, state_key } = session; + private removeSession( + sessionId: string, + userId: string, + memberId: string, + ): void { + const session = this.sessions.find((s) => s.sessionId === sessionId); + if (session === undefined) { + return; + } this.logger.debug( - `Session ${state_key} by ${sender} joined whiteboard ${session.content.call_id}`, + `Session ${sessionId} left whiteboard, user ${userId}, member: ${memberId}`, ); - this.sessions = [...this.sessions, session]; - this.sessionJoinedSubject.next({ sessionId: state_key, userId: sender }); - } + this.sessions = this.sessions.filter((s) => s.sessionId !== sessionId); - private removeSession(sessionId: string, userId: string): void { - this.logger.debug(`Session ${sessionId} left whiteboard`); + this.logger.debug( + 'Sessions updated', + JSON.stringify(this.sessions), + 'length', + this.sessions.length, + ); - this.sessions = this.sessions.filter((s) => s.state_key !== sessionId); this.sessionLeftSubject.next({ - sessionId, - userId, + sessionId: session.sessionId, + userId: session.userId, + memberId: session.memberId, + livekitTransport: session.livekitTransport, }); } - private async refreshOwnSession( - sessionId: string | undefined, + private async sendRtcMemberJoinEvent( + memberId: string, whiteboardId: string, + transports: Transport[], ): Promise { - const expires = Date.now() + this.sessionTimeout; + this.logger.debug( + `Sending RTC member join event for memberId: ${memberId}`, + ); + const widgetApi = await this.widgetApiPromise; const { userId, deviceId } = widgetApi.widgetParameters; - this.logger.debug(`Refreshing session ${sessionId}`); - - if (!userId || !deviceId || !whiteboardId) { - // @todo this needs to be handled better so it bubbles up to the user - this.logger.error( - 'Unknown user id or device id or whiteboard id when patching RTC session', - ); - throw new Error('Unknown user id or device id or whiteboard id'); + if (!userId || !deviceId) { + throw new Error('Unknown user id or device id '); } + const rtcMemberJoin: RtcMemberJoin = { + slot_id: `net.nordeck.whiteboard#${whiteboardId}`, + member: { + id: memberId, + membership: 'join', + device_id: deviceId, + }, + application: { + type: 'net.nordeck.whiteboard', + whiteboard_id: whiteboardId, + }, + transports: { + published: transports, + can_subscribe: ['livekit'], + }, + msc4354_sticky_key: memberId, + }; try { - // Get the existing session event, if any - const sessionEvent = (await widgetApi.receiveSingleStateEvent( - STATE_EVENT_RTC_MEMBER, - sessionId, - )) as StateEvent; - - // Determine the base session object - let baseSession: RTCSessionEventContent; - - if ( - sessionEvent && - Object.keys(sessionEvent.content).length !== 0 && - isWhiteboardRTCSessionStateEvent(sessionEvent) - ) { - // If a valid session exists, clone it - baseSession = clone(sessionEvent.content); - } else { - // Otherwise create a new session event content - baseSession = newRTCSession(deviceId, whiteboardId); - } - - // make sure the livekit alias for foci is set to the current room - const foci_preferred = this.fociPreferred.map((focus) => { - if (focus.type === 'livekit') { - return { - ...focus, - livekit_alias: widgetApi.widgetParameters.roomId, - }; - } - return focus; + await widgetApi.sendRoomEvent(ROOM_EVENT_4143_RTC_MEMBER, rtcMemberJoin, { + stickyDurationMs: this.stickyDurationMs, }); - - const updatedSession: RTCSessionEventContent = { - ...baseSession, - expires, - foci_preferred, - }; - - // Check if session has been modified compared to the original - if (!isEqual(updatedSession, sessionEvent)) { - await widgetApi.sendStateEvent(STATE_EVENT_RTC_MEMBER, updatedSession, { - stateKey: `_${userId}_${deviceId}`, - }); - this.logger.debug('RTC session sent', JSON.stringify(updatedSession)); - } } catch (ex) { - this.logger.error('Error while sending RTC session', ex); + this.logger.error('Error while sending RTC member join event', ex); } } - private async endRtcSession(sessionId: string): Promise { - const widgetApi = await this.widgetApiPromise; - const { userId, deviceId } = widgetApi.widgetParameters; - + private async sendRtcMemberLeaveEvent( + userId: string, + deviceId: string, + memberId: string, + whiteboardId: string, + ): Promise { this.logger.debug( - 'Ending RTC session with empty content in membership state', - userId, - deviceId, - sessionId, + `Sending RTC member leave event for userId: ${userId}, deviceId: ${deviceId}, memberId: ${memberId}`, ); + const widgetApi = await this.widgetApiPromise; + + const rtcMemberLeave: RtcMemberLeave = { + slot_id: `net.nordeck.whiteboard#${whiteboardId}`, + member: { + id: memberId, + membership: 'leave', + device_id: deviceId, + }, + leave_reason: { + code: 'leave', + }, + msc4354_sticky_key: memberId, + }; try { - await widgetApi.sendStateEvent( - STATE_EVENT_RTC_MEMBER, - {}, - { stateKey: `_${userId}_${deviceId}` }, + await widgetApi.sendRoomEvent( + ROOM_EVENT_4143_RTC_MEMBER, + rtcMemberLeave, + { stickyDurationMs: this.stickyDurationMs }, ); } catch (ex) { - this.logger.error('Error while sending RTC session', ex); + this.logger.error('Error while sending RTC member leave event', ex); } } } + +function getLivekitTransport(transports: Transport[]): { + livekitServiceUrl: string; +} { + const transport = transports.find(isLivekitTransport); + + if (!transport) { + throw new Error('Could not find livekit transport'); + } + + return { + livekitServiceUrl: transport.livekit_service_url, + }; +} diff --git a/packages/react-sdk/src/state/communication/discovery/sessionManagerImpl.ts b/packages/react-sdk/src/state/communication/discovery/sessionManagerImpl.ts index fa9f2acb1..70b7cdd51 100644 --- a/packages/react-sdk/src/state/communication/discovery/sessionManagerImpl.ts +++ b/packages/react-sdk/src/state/communication/discovery/sessionManagerImpl.ts @@ -37,7 +37,7 @@ import { } from '../../../model'; import { Session, SessionManager } from './types'; -export type SessionState = { userId: string } & WhiteboardSession; +type SessionState = { userId: string } & WhiteboardSession; export class SessionManagerImpl implements SessionManager { private readonly logger = getLogger('SessionManager'); @@ -61,6 +61,7 @@ export class SessionManagerImpl implements SessionManager { return this.joinState?.sessionId; } + /** Gets a list of all active sessions, excluding the own session. */ getSessions(): Session[] { return this.sessions.map(({ sessionId, userId }) => ({ sessionId, @@ -68,19 +69,24 @@ export class SessionManagerImpl implements SessionManager { })); } + /** + * Observes new sessions that joined the current whiteboard. + * Is never triggered for the own session. + */ observeSessionJoined(): Observable { return this.sessionJoinedSubject; } + /** + * Observes sessions that left the current whiteboard, like expired + * sessions. + * Is never triggered for the own session. + */ observeSessionLeft(): Observable { return this.sessionLeftSubject; } - observeSession(): Observable { - return this.sessionSubject; - } - - async join(whiteboardId: string): Promise<{ sessionId: string }> { + async join(whiteboardId: string): Promise { if (this.joinState) { await this.leave(); } @@ -133,7 +139,13 @@ export class SessionManagerImpl implements SessionManager { // Write session initially await this.refreshOwnSession(whiteboardId, sessionId); - return { sessionId }; + const widgetApi = await this.widgetApiPromise; + const { userId } = widgetApi.widgetParameters; + + if (!userId) { + throw new Error('Unknown user id'); + } + return { userId, sessionId }; } async leave(): Promise { diff --git a/packages/react-sdk/src/state/communication/discovery/types.ts b/packages/react-sdk/src/state/communication/discovery/types.ts index fe5e85625..e99f0bba7 100644 --- a/packages/react-sdk/src/state/communication/discovery/types.ts +++ b/packages/react-sdk/src/state/communication/discovery/types.ts @@ -15,36 +15,28 @@ */ import { Observable } from 'rxjs'; -import { RTCFocus } from './matrixRtcFocus'; -import { SessionState } from './sessionManagerImpl'; export type Session = { userId: string; sessionId: string }; -export type SessionManager = { +export type SessionManager = { /** Gets the current session id, if joined. */ getSessionId(): string | undefined; - /** Gets a list of all active sessions, excluding the own session. */ - getSessions(): Session[]; + /** Gets a list of active sessions */ + getSessions(): S[]; /** * Observes new sessions that joined the current whiteboard. - * Is never triggered for the own session. */ - observeSessionJoined(): Observable; + observeSessionJoined(): Observable; /** - * Observes sessions that left the current whiteboard, like expired + * Observes sessions that left the current whiteboard * sessions. - * Is never triggered for the own session. */ - observeSessionLeft(): Observable; - /** - * Observes sessions in the current whiteboard. - */ - observeSession(): Observable; + observeSessionLeft(): Observable; /** * Join a whiteboard session. * Session join and leave events are related to the joined whiteboard. */ - join(whiteboardId: string): Promise<{ sessionId: string }>; + join(whiteboardId: string): Promise; /** * Leaves the current whiteboard. */ @@ -55,25 +47,3 @@ export type SessionManager = { */ destroy(): void; }; - -export type MatrixRtcSessionManager = SessionManager & { - /** Initialize RTC Foci Discovery */ - initFociDiscovery(): void; - /** Gets the current active focus, if discovered */ - getActiveFocus(): RTCFocus | undefined; - /** - * Observe active focus changes. - * This will emit the current active focus when it changes. - */ - observeActiveFocus(): Observable; -}; - -export function isMatrixRtcSessionManager( - manager: SessionManager, -): manager is MatrixRtcSessionManager { - return ( - typeof (manager as MatrixRtcSessionManager).getActiveFocus === 'function' && - typeof (manager as MatrixRtcSessionManager).observeActiveFocus === - 'function' - ); -} diff --git a/packages/react-sdk/src/state/communication/index.ts b/packages/react-sdk/src/state/communication/index.ts index 7295dc5ba..86d0b51ef 100644 --- a/packages/react-sdk/src/state/communication/index.ts +++ b/packages/react-sdk/src/state/communication/index.ts @@ -23,5 +23,6 @@ export type { CommunicationChannelStatistics, Message, PeerConnectionStatistics, + SessionStatistics, } from './types'; export { WebRtcCommunicationChannel } from './webRtcCommunicationChannel'; diff --git a/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.test.ts b/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.test.ts index f309c150c..038b537af 100644 --- a/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.test.ts +++ b/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.test.ts @@ -14,10 +14,9 @@ * limitations under the License. */ -import { WidgetApi } from '@matrix-widget-toolkit/api'; import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; -import { ConnectionState } from 'livekit-client'; -import { BehaviorSubject, Subject, firstValueFrom, toArray } from 'rxjs'; +import clone from 'lodash/clone'; +import { BehaviorSubject, Subject, firstValueFrom, take, toArray } from 'rxjs'; import { Mocked, afterEach, @@ -28,29 +27,19 @@ import { vi, } from 'vitest'; import { mockDocumentVisibilityState } from '../../lib/testUtils/domTestUtils'; -import { PeerConnectionStatistics } from './connection'; -import { PeerConnection } from './connection/types'; -import { MatrixRtcSessionManagerImpl, RTCFocus } from './discovery'; -import AutoDiscovery from './discovery/autodiscovery'; +import { + MatrixRtcPeerConnection, + Message, + PeerConnection, + PeerConnectionStatistics, +} from './connection'; +import { MatrixRtcSession, Session, SessionManager } from './discovery'; +import AutoDiscovery, { SFUConfig } from './discovery/autodiscovery'; import { MatrixRtcCommunicationChannel } from './matrixRtcCommunicationChannel'; -const mockPeerConnection = { - close: vi.fn(), - observeMessages: vi.fn().mockReturnValue(new Subject()), - observeStatistics: vi.fn(), - getRemoteSessionId: vi.fn(() => 'remote-session-id'), - getConnectionId: vi.fn().mockReturnValue('connection-id'), - observeConnectionState: vi.fn(), - sendMessage: vi.fn(), - updateStatistics: vi.fn(), - handleDataReceived: vi.fn(), -} as unknown as Mocked; - vi.mock('./connection', () => { return { - MatrixRtcPeerConnection: vi - .fn() - .mockImplementation(() => mockPeerConnection), + MatrixRtcPeerConnection: vi.fn(), }; }); @@ -70,35 +59,121 @@ afterEach(() => { }); describe('MatrixRtcCommunicationChannel', () => { - let sessionManager: Mocked; + const ownSession: MatrixRtcSession = { + sessionId: 'session-id', + userId: '@user-id:example.com', + memberId: 'member-id', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }; + const peerConnectionStatistics: Partial = { + localParticipantIdentity: 'session-id', + remoteParticipantIdentities: [], + bytesReceived: 0, + bytesSent: 0, + packetsReceived: 0, + packetsSent: 0, + connectionState: 'new', + }; + + let sessionManager: Mocked>; + let peerConnection: Mocked; + let peerConnectionAnother: Mocked; let channel: MatrixRtcCommunicationChannel; - let activeFocusSubject: Subject; - let statisticsSubject: Subject; - let connectionStateSubject: Subject; + let joinedSubject: Subject; + let leftSubject: Subject; + let statisticsSubject: Subject>; + let statisticsAnotherSubject: Subject>; + let messageSubject: Subject; + let messageSubjectAnother: Subject; let enableObserveVisibilityStateSubject: Subject; - let currentSessionId: string | undefined; - - const mockActiveFocus: RTCFocus = { - type: 'livekit', - livekit_service_url: 'http://mock-livekit-server.example.com', - }; beforeEach(() => { mockDocumentVisibilityState('visible'); - activeFocusSubject = new Subject(); statisticsSubject = new Subject(); - connectionStateSubject = new Subject(); + statisticsAnotherSubject = new Subject(); + messageSubject = new Subject(); + messageSubjectAnother = new Subject(); enableObserveVisibilityStateSubject = new BehaviorSubject(true); - mockPeerConnection.observeStatistics.mockReturnValue(statisticsSubject); - mockPeerConnection.observeConnectionState.mockReturnValue( - connectionStateSubject, + joinedSubject = new Subject(); + leftSubject = new Subject(); + + let currentSessionId: string | undefined; + sessionManager = { + getSessionId: vi.fn(() => currentSessionId), + getSessions: vi.fn().mockReturnValue([]), + observeSessionJoined: vi.fn().mockReturnValue(joinedSubject), + observeSessionLeft: vi.fn().mockReturnValue(leftSubject), + join: vi.fn().mockImplementation(async () => { + const sessionId = 'session-id'; + currentSessionId = sessionId; + const session: MatrixRtcSession = { + userId: '@user-id:example.com', + sessionId, + memberId: 'member-id', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }; + return session; + }), + leave: vi.fn().mockImplementation(async () => { + currentSessionId = undefined; + }), + destroy: vi.fn(), + }; + let peerConnectionCount = 0; + vi.mocked(MatrixRtcPeerConnection).mockImplementation( + (livekitServiceUrl) => { + const newPeerConnection = mockPeerConnection({ + connectionId: livekitServiceUrl, + messageSubject: + peerConnectionCount === 0 ? messageSubject : messageSubjectAnother, + statisticsSubject: + peerConnectionCount === 0 + ? statisticsSubject + : statisticsAnotherSubject, + }); + if (peerConnectionCount === 0) { + peerConnection = newPeerConnection; + } else { + peerConnectionAnother = newPeerConnection; + } + peerConnectionCount++; + return newPeerConnection as unknown as MatrixRtcPeerConnection; + }, + ); + vi.spyOn(AutoDiscovery, 'getSFUConfigWithOpenID').mockImplementation( + (_widgetApi, activeFocus) => { + let sfuConfig: SFUConfig; + if ( + activeFocus.livekit_service_url === 'https://livekit-jwt.example.com' + ) { + sfuConfig = { + url: 'wss://mock-livekit-server.example.com', + jwt: 'mock-jwt-token', + }; + } else { + sfuConfig = { + url: 'wss://mock-livekit-server.example-1.com', + jwt: 'mock-jwt-token-another', + }; + } + + return Promise.resolve(sfuConfig); + }, ); - mockPeerConnection.observeMessages.mockReturnValue(new Subject()); - createSessionManager(); - createChannel(); + channel = new MatrixRtcCommunicationChannel( + widgetApi, + sessionManager, + 'whiteboard-id', + enableObserveVisibilityStateSubject, + 250, + ); }); afterEach(() => { @@ -106,55 +181,318 @@ describe('MatrixRtcCommunicationChannel', () => { vi.clearAllMocks(); }); - it('should create a peer connection and join when connected', async () => { - await waitForHandlers(); - connectionStateSubject.next(ConnectionState.Connected); - - expect(sessionManager.join).toHaveBeenCalledTimes(1); + it('should join', async () => { + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); + expect(sessionManager.join).toHaveBeenCalledWith('whiteboard-id'); + expect(AutoDiscovery.getSFUConfigWithOpenID).toHaveBeenCalledWith( + {}, + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example.com', + livekit_alias: '!room-id:example.com', + }, + 'net.nordeck.whiteboard#whiteboard-id', + '@user-id:example.com', + 'DEVICEID', + 'member-id', + ); + expect(AutoDiscovery.getSFUConfigWithOpenID).toHaveBeenCalledTimes(1); expect(sessionManager.getSessionId()).toBe('session-id'); + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id', + memberId: 'member-id', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: { + 'https://livekit-jwt.example.com': peerConnectionStatistics, + }, + sessions: { + 'session-id': { + userId: '@user-id:example.com', + }, + }, + }); + }); + + it('should re-join on leave', async () => { + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); + expect(sessionManager.join).toHaveBeenCalledWith('whiteboard-id'); + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id', + memberId: 'member-id', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: { + 'https://livekit-jwt.example.com': peerConnectionStatistics, + }, + sessions: { + 'session-id': { + userId: '@user-id:example.com', + }, + }, + }); + + sessionManager.join.mockResolvedValue({ + userId: '@user-id:example.com', + sessionId: 'session-id-1', + memberId: '$member-id-1', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }); + + const userLeaveSession: MatrixRtcSession = { + sessionId: 'session-id', + userId: '@user-id:example.com', + memberId: 'member-id', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }; + leftSubject.next(userLeaveSession); + + joinedSubject.next({ + sessionId: 'session-id-1', + userId: '@user-id:example.com', + memberId: '$member-id-1', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }); + + expect(sessionManager.join).toHaveBeenCalledTimes(2); + + // check that existing peer connection is closed await vi.waitFor(() => { - expect(channel.getStatistics().localSessionId).toBe('session-id'); + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id-1', + memberId: '$member-id-1', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: {}, + sessions: { + 'session-id-1': { + userId: '@user-id:example.com', + }, + }, + }); + }); + + // check that a new peer connection is created + await waitForSessionExists(statisticsAnotherSubject); + await vi.waitFor(() => { + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id-1', + memberId: '$member-id-1', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: { + 'https://livekit-jwt.example.com': peerConnectionStatistics, + }, + sessions: { + 'session-id-1': { + userId: '@user-id:example.com', + }, + }, + }); }); }); + it('should join and another user with another transport joins', async () => { + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); + + const anotherSession: MatrixRtcSession = { + sessionId: 'another-session-id', + userId: '@another-user-id:example-1.com', + memberId: '$member-id-0', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example-1.com', + }, + }; + joinedSubject.next(anotherSession); + + await vi.waitFor(() => { + statisticsAnotherSubject.next({ + ...peerConnectionStatistics, + remoteParticipantIdentities: ['another-session-id'], + }); + expect( + Object.values(channel.getStatistics().peerConnections).length, + ).toBe(2); + }); + + expect(AutoDiscovery.getSFUConfigWithOpenID).toHaveBeenNthCalledWith( + 2, + {}, + { + type: 'livekit', + livekit_service_url: 'https://livekit-jwt.example-1.com', + livekit_alias: '!room-id:example.com', + }, + 'net.nordeck.whiteboard#whiteboard-id', + '@user-id:example.com', + 'DEVICEID', + 'member-id', + ); + expect(AutoDiscovery.getSFUConfigWithOpenID).toHaveBeenCalledTimes(2); + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id', + memberId: 'member-id', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: { + 'https://livekit-jwt.example.com': peerConnectionStatistics, + 'https://livekit-jwt.example-1.com': { + ...peerConnectionStatistics, + remoteParticipantIdentities: ['another-session-id'], + }, + }, + sessions: { + 'session-id': { + userId: '@user-id:example.com', + }, + 'another-session-id': { + userId: '@another-user-id:example-1.com', + }, + }, + }); + }); + + it('should join and another user with same transport joins', async () => { + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); + + const anotherSession: MatrixRtcSession = { + sessionId: 'another-session-id', + userId: '@another-user-id:example.com', + memberId: '$member-id-0', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }; + joinedSubject.next(anotherSession); + + await vi.waitFor(() => { + statisticsSubject.next({ + ...peerConnectionStatistics, + remoteParticipantIdentities: ['another-session-id'], + }); + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id', + memberId: 'member-id', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: { + 'https://livekit-jwt.example.com': { + ...peerConnectionStatistics, + remoteParticipantIdentities: ['another-session-id'], + }, + }, + sessions: { + 'session-id': { + userId: '@user-id:example.com', + }, + 'another-session-id': { + userId: '@another-user-id:example.com', + }, + }, + }); + }); + + expect(AutoDiscovery.getSFUConfigWithOpenID).toHaveBeenCalledTimes(1); + }); + it('should disconnect while the browser is hidden', async () => { - await waitForHandlers(); - connectionStateSubject.next(ConnectionState.Connected); + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); expect(sessionManager.join).toHaveBeenCalledTimes(1); + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id', + memberId: 'member-id', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: { + 'https://livekit-jwt.example.com': peerConnectionStatistics, + }, + sessions: { + 'session-id': { + userId: '@user-id:example.com', + }, + }, + }); + vi.useFakeTimers(); - sessionManager.leave.mockClear(); // Hide the tab mockDocumentVisibilityState('hidden'); vi.advanceTimersByTime(250); - expect(sessionManager.leave).toHaveBeenCalledTimes(1); + expect(sessionManager.leave).toHaveBeenCalled(); + await vi.waitFor(() => { + expect(peerConnection.close).toHaveBeenCalled(); + }); await vi.waitFor(() => { expect(sessionManager.getSessionId()).toBeUndefined(); }); - sessionManager.getActiveFocus.mockClear(); + // check that existing peer connection is closed + expect(channel.getStatistics()).toEqual({ + peerConnections: {}, + sessions: {}, + }); + + sessionManager.join.mockResolvedValue({ + userId: '@user-id:example.com', + sessionId: 'session-id-1', + memberId: '$member-id-1', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + }); // Show the tab mockDocumentVisibilityState('visible'); - connectionStateSubject.next(ConnectionState.Connected); - expect(sessionManager.getActiveFocus).toHaveBeenCalledTimes(1); expect(sessionManager.join).toHaveBeenCalledTimes(2); + + // check that a new peer connection is created + await waitForSessionExists(statisticsAnotherSubject); + await vi.waitFor(() => { + expect(channel.getStatistics()).toEqual({ + localSession: { + sessionId: 'session-id-1', + memberId: '$member-id-1', + livekitServiceUrl: 'https://livekit-jwt.example.com', + }, + peerConnections: { + 'https://livekit-jwt.example.com': peerConnectionStatistics, + }, + sessions: {}, + }); + }); }); it('should not attempt to connect when destroyed but browser becomes visible', async () => { - await waitForHandlers(); - connectionStateSubject.next(ConnectionState.Connected); + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); expect(sessionManager.join).toHaveBeenCalledTimes(1); vi.useFakeTimers(); - sessionManager.leave.mockClear(); // Hide the tab mockDocumentVisibilityState('hidden'); @@ -162,6 +500,9 @@ describe('MatrixRtcCommunicationChannel', () => { vi.advanceTimersByTime(250); expect(sessionManager.leave).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(peerConnection.close).toHaveBeenCalled(); + }); await vi.waitFor(() => { expect(sessionManager.getSessionId()).toBeUndefined(); @@ -169,37 +510,14 @@ describe('MatrixRtcCommunicationChannel', () => { channel.destroy(); - sessionManager.getActiveFocus.mockClear(); - // Show the tab mockDocumentVisibilityState('visible'); - expect(sessionManager.getActiveFocus).not.toHaveBeenCalled(); - }); - - it('should not attempt to connect when enable visibility observation emits and document visibility is visible and user is connected', async () => { - await waitForHandlers(); - connectionStateSubject.next(ConnectionState.Connected); - expect(sessionManager.join).toHaveBeenCalledTimes(1); - - expect(sessionManager.getSessionId()).toBe('session-id'); - await vi.waitFor(() => { - expect(channel.getStatistics().localSessionId).toBe('session-id'); - }); - - // Emits a new distinct value - enableObserveVisibilityStateSubject.next(false); - - // needed to make sure the leave mock below holds proper called times - await Promise.resolve(); - - expect(sessionManager.leave).toHaveBeenCalledTimes(1); }); it('should skip disconnect while the browser is hidden if disabled', async () => { - connectionStateSubject.next(ConnectionState.Connected); - + joinedSubject.next(ownSession); vi.useFakeTimers(); sessionManager.leave.mockClear(); @@ -210,58 +528,105 @@ describe('MatrixRtcCommunicationChannel', () => { vi.advanceTimersByTime(1250); expect(sessionManager.leave).not.toHaveBeenCalled(); + expect(peerConnection.close).not.toHaveBeenCalled(); }); - it('should handle messages from peer connections', async () => { - connectionStateSubject.next(ConnectionState.Connected); + it('should receive messages from any peer connection', async () => { + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); - const messagesPromise = firstValueFrom(channel.observeMessages()); + const anotherSession: MatrixRtcSession = { + sessionId: 'another-session-id', + userId: '@another-user-id:example-1.com', + memberId: '$member-id-0', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example-1.com', + }, + }; + joinedSubject.next(anotherSession); - // @ts-ignore - channel.messagesSubject.next({ - type: 'example_type', - content: { key: 'value' }, - senderSessionId: 'another-session-id', - senderUserId: '@another-user-id:example.com', + await vi.waitFor(() => { + statisticsAnotherSubject.next({ + ...peerConnectionStatistics, + remoteParticipantIdentities: ['another-session-id'], + }); + expect( + Object.values(channel.getStatistics().peerConnections).length, + ).toBe(2); }); - await expect(messagesPromise).resolves.toEqual({ + const messagesPromise = firstValueFrom( + channel.observeMessages().pipe(take(2), toArray()), + ); + + messageSubject.next({ type: 'example_type', content: { key: 'value' }, + senderSessionId: 'session-id', + senderUserId: '@user-id:example.com', + }); + messageSubjectAnother.next({ + type: 'example_type', + content: { key: 'another-value' }, senderSessionId: 'another-session-id', senderUserId: '@another-user-id:example.com', }); - }); - - it('should send messages to peer connections', async () => { - await waitForHandlers(); - connectionStateSubject.next(ConnectionState.Connected); - - channel.broadcastMessage('example_type', { key: 'value' }); - expect(mockPeerConnection.sendMessage).toHaveBeenCalledWith( - 'example_type', + await expect(messagesPromise).resolves.toEqual([ { - key: 'value', + type: 'example_type', + content: { key: 'value' }, + senderSessionId: 'session-id', + senderUserId: '@user-id:example.com', }, - ); + { + type: 'example_type', + content: { key: 'another-value' }, + senderSessionId: 'another-session-id', + senderUserId: '@another-user-id:example.com', + }, + ]); }); - it('should leave when disconnected', async () => { - await waitForHandlers(); - connectionStateSubject.next(ConnectionState.Connected); + it('should send messages to own peer connection', async () => { + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); - expect(sessionManager.join).toHaveBeenCalledTimes(1); - - connectionStateSubject.next(ConnectionState.Disconnected); + const anotherSession: MatrixRtcSession = { + sessionId: 'another-session-id', + userId: '@another-user-id:example-1.com', + memberId: '$member-id-0', + livekitTransport: { + livekitServiceUrl: 'https://livekit-jwt.example-1.com', + }, + }; + joinedSubject.next(anotherSession); await vi.waitFor(() => { - expect(sessionManager.leave).toHaveBeenCalled(); + statisticsAnotherSubject.next({ + ...peerConnectionStatistics, + remoteParticipantIdentities: ['another-session-id'], + }); + expect( + Object.values(channel.getStatistics().peerConnections).length, + ).toBe(2); }); + + channel.broadcastMessage('example_type', { key: 'value' }, undefined); + + expect(peerConnection.sendMessage).toHaveBeenCalledWith( + 'example_type', + { + key: 'value', + }, + undefined, + ); + expect(peerConnectionAnother.sendMessage).not.toHaveBeenCalled(); }); it('should leave after destroying', async () => { - connectionStateSubject.next(ConnectionState.Connected); + joinedSubject.next(ownSession); + await waitForSessionExists(statisticsSubject); const messagesPromise = firstValueFrom( channel.observeMessages().pipe(toArray()), @@ -279,45 +644,64 @@ describe('MatrixRtcCommunicationChannel', () => { }); }); - // Waits for observers handlers to be registered - async function waitForHandlers() { - // empty, to wait for Observer handlers to be registered + /** + * Waits for channel statistics to be updated, + * so peer connection is created and received the statistics. + */ + async function waitForSessionExists( + statisticsSubject: Subject>, + ) { + await vi.waitFor(() => { + statisticsSubject.next(peerConnectionStatistics); + expect( + Object.values(channel.getStatistics().peerConnections).length, + ).toBe(1); + }); } +}); - function createSessionManager() { - sessionManager = vi.mocked( - Object.assign(new MatrixRtcSessionManagerImpl(widgetApi as WidgetApi), { - getSessionId: vi.fn(() => currentSessionId), - getActiveFocus: vi.fn().mockReturnValue(mockActiveFocus), - observeActiveFocus: vi.fn().mockReturnValue(activeFocusSubject), - join: vi.fn().mockImplementation(async () => { - const sessionId = 'session-id'; - currentSessionId = sessionId; - return { sessionId }; - }), - leave: vi.fn().mockImplementation(async () => { - currentSessionId = undefined; - }), - destroy: vi.fn(), - }), - ); - } +function mockPeerConnection({ + remoteSessionId = 'remote-session-id', + connectionId = 'connection-id', + messageSubject, + statisticsSubject: incomingStatisticsSubject, +}: { + remoteSessionId?: string; + remoteUserId?: string; + connectionId?: string; + messageSubject: Subject; + statisticsSubject: Subject>; +}): Mocked { + const statisticsSubject = new Subject(); + const statistics: PeerConnectionStatistics = { + bytesReceived: 0, + bytesSent: 0, + packetsReceived: 0, + packetsSent: 0, + connectionState: 'disconnected', + }; - function createChannel() { - channel = new MatrixRtcCommunicationChannel( - widgetApi, - sessionManager as MatrixRtcSessionManagerImpl, - 'whiteboard-id', - enableObserveVisibilityStateSubject, - 250, - ); + function updateStatistics(update: Partial): void { + if (Object.keys(update).length > 0) { + Object.assign(statistics, update); - const mockGetSFUConfigWithOpenID = vi.fn().mockResolvedValue({ - url: 'wss://mock-livekit-server.example.com', - jwt: 'mock-jwt-token', - }); - vi.spyOn(AutoDiscovery, 'getSFUConfigWithOpenID').mockImplementation( - mockGetSFUConfigWithOpenID, - ); + statisticsSubject.next(clone(statistics)); + } } -}); + + incomingStatisticsSubject.subscribe((incomingStatistics) => { + updateStatistics(incomingStatistics); + }); + + return { + getRemoteSessionId: vi.fn(() => remoteSessionId), + getConnectionId: vi.fn().mockReturnValue(connectionId), + close: vi.fn().mockImplementation(() => { + statisticsSubject.complete(); + }), + sendMessage: vi.fn(), + observeMessages: vi.fn().mockReturnValue(messageSubject), + observeStatistics: vi.fn().mockReturnValue(statisticsSubject), + observeConnectionState: vi.fn(), + }; +} diff --git a/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.ts b/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.ts index 7bbcc055a..e48eea2d3 100644 --- a/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.ts +++ b/packages/react-sdk/src/state/communication/matrixRtcCommunicationChannel.ts @@ -15,9 +15,9 @@ */ import { WidgetApi } from '@matrix-widget-toolkit/api'; -import { ConnectionState } from 'livekit-client'; import cloneDeep from 'lodash/cloneDeep'; import { getLogger } from 'loglevel'; +import { IOpenIDCredentials } from 'matrix-widget-api'; import { BehaviorSubject, distinctUntilChanged, @@ -27,28 +27,23 @@ import { switchMap, takeUntil, } from 'rxjs'; -import { MatrixRtcPeerConnection, PeerConnection } from './connection'; +import { getServerNameFromUserId } from '../../lib'; import { - isLivekitFocusConfig, - LivekitFocus, - RTCFocus, - Session, -} from './discovery'; -import AutoDiscovery from './discovery/autodiscovery'; -import { SessionState } from './discovery/sessionManagerImpl'; -import { MatrixRtcSessionManager } from './discovery/types'; -import { - CommunicationChannel, - CommunicationChannelStatistics, + MatrixRtcPeerConnection, Message, + MessageOptions, + PeerConnection, PeerConnectionStatistics, -} from './types'; +} from './connection'; +import { MatrixRtcSession, SessionManager } from './discovery'; +import AutoDiscovery from './discovery/autodiscovery'; +import { CommunicationChannel, CommunicationChannelStatistics } from './types'; import { observeVisibilityState } from './visibilityState'; -export interface SFUConfig { - url: string; - jwt: string; -} +type PeerConnectionWrapper = { + connectionId: string; + connection?: PeerConnection; +}; export class MatrixRtcCommunicationChannel implements CommunicationChannel { private readonly logger = getLogger('MatrixRtcCommunicationChannel'); @@ -56,16 +51,17 @@ export class MatrixRtcCommunicationChannel implements CommunicationChannel { private readonly messagesSubject = new Subject(); private readonly statisticsSubject = new Subject(); + + private readonly peerConnections: Map = + new Map(); private readonly statistics: CommunicationChannelStatistics = { peerConnections: {}, - sessions: [], + sessions: {}, }; - private readonly peerConnections: PeerConnection[] = []; - private sfuConfig: SFUConfig | undefined; constructor( private readonly widgetApiPromise: Promise | WidgetApi, - private readonly sessionManager: MatrixRtcSessionManager, + private readonly sessionManager: SessionManager, private readonly whiteboardId: string, onEnableObserveVisibilityState: Observable = new BehaviorSubject( true, @@ -74,32 +70,28 @@ export class MatrixRtcCommunicationChannel implements CommunicationChannel { ) { this.logger.log('Creating communication channel'); - this.sessionManager.initFociDiscovery(); - this.sessionManager - .observeActiveFocus() + .observeSessionJoined() .pipe(takeUntil(this.destroySubject)) - .subscribe({ - next: (focus) => { - this.logger.debug('RTC Focus update received', focus); - this.initFocusBackend(focus); - }, - error: (error) => { - this.logger.error('Error while updating focus', error); - }, - }); + .subscribe(this.handleSessionJoined.bind(this)); this.sessionManager - .observeSession() - .pipe(takeUntil(this.destroySubject)) - .subscribe((session) => { - this.logger.debug('Got session changes for', session); - if (session.expiresTs !== undefined) { - this.addSessionStatistics(session); - } else { - this.removeSessionStatistics(session); - } - }); + .observeSessionLeft() + .pipe( + takeUntil( + // Wait with unsubscribing the session left events till we processed + // all of then, which is after completing the disconnect + this.destroySubject.pipe( + mergeMap(async () => { + this.logger.log( + 'Communication channel destroyed, disconnecting…', + ); + await this.disconnect(); + }), + ), + ), + ) + .subscribe(this.handleSessionLeft.bind(this)); // If the tab is in the background, we want to disconnect from the room to // save resources. We reconnect once the tab is active again. @@ -112,23 +104,14 @@ export class MatrixRtcCommunicationChannel implements CommunicationChannel { takeUntil(this.destroySubject), mergeMap(async (v) => { if (v === 'visible') { - if (this.statistics.localSessionId) { + if (this.statistics.localSession) { // already connected return; } try { - const activeFocus = this.sessionManager.getActiveFocus(); - if (activeFocus) { - this.logger.log( - 'Visibility changed to visible, connecting…', - ); - await this.initFocusBackend(activeFocus); - } else { - this.logger.warn( - 'Visibility changed to visible but no active focus found, not connecting', - ); - } + this.logger.log('Visibility changed to visible, connecting…'); + await this.connect(); } catch (err) { this.logger.error( 'Error while connecting to focus backend', @@ -155,8 +138,19 @@ export class MatrixRtcCommunicationChannel implements CommunicationChannel { .subscribe(); } - broadcastMessage(type: string, content: T): void { - this.peerConnections.forEach((c) => c.sendMessage(type, content)); + broadcastMessage( + type: string, + content: T, + options?: MessageOptions, + ): void { + this.peerConnections.forEach(({ connectionId, connection }) => { + if ( + connectionId === this.statistics.localSession?.livekitServiceUrl && + connection + ) { + connection.sendMessage(type, content, options); + } + }); } observeMessages(): Observable { @@ -169,10 +163,6 @@ export class MatrixRtcCommunicationChannel implements CommunicationChannel { this.messagesSubject.complete(); this.statisticsSubject.complete(); this.destroySubject.next(); - - this.disconnect().catch((err) => { - this.logger.error('Error while disconnecting communication channel', err); - }); } getStatistics(): CommunicationChannelStatistics { @@ -184,116 +174,213 @@ export class MatrixRtcCommunicationChannel implements CommunicationChannel { } private async connect() { - if (this.statistics.localSessionId) { + if (this.statistics.localSession) { this.logger.log('Communication channel is already open'); return; } this.logger.log('Connecting communication channel'); - const { sessionId } = await this.sessionManager.join(this.whiteboardId); - - this.statistics.localSessionId = sessionId; + const { + sessionId, + memberId, + livekitTransport: { livekitServiceUrl }, + } = await this.sessionManager.join(this.whiteboardId); + + this.statistics.localSession = { + sessionId, + memberId, + livekitServiceUrl: livekitServiceUrl, + }; this.statisticsSubject.next(cloneDeep(this.statistics)); + + await this.ensurePeerConnectionExists(livekitServiceUrl, memberId); } private async disconnect(): Promise { this.logger.log('Disconnecting communication channel'); - this.sfuConfig = undefined; - await this.sessionManager.leave(); - - this.statistics.localSessionId = undefined; + // Reset all statistics first before leave to not reconnect + // when session leave event is sent to channel from session manager + this.statistics.localSession = undefined; + this.statistics.sessions = {}; this.statisticsSubject.next(cloneDeep(this.statistics)); - this.statistics.sessions = []; - this.peerConnections.forEach((c) => c.close()); + await this.sessionManager.leave(); + + this.peerConnections.forEach((c) => { + if (c.connection) { + c.connection.close(); + } + }); + this.peerConnections.clear(); } - private async initFocusBackend(focus: RTCFocus): Promise { - this.logger.debug('Initializing focus backend with focus', focus); + private async handleSessionJoined(session: MatrixRtcSession): Promise { + this.logger.log('Joined', session.sessionId, session.userId); + this.addSessionStatistics(session.sessionId, session); - if (focus.type !== 'livekit') { - this.logger.error( - 'Unable to init focus backend due to unsupported active focus type', - focus.type, - ); - throw new Error('Unsupported active focus type'); + const widgetApi = await this.widgetApiPromise; + const { userId } = widgetApi.widgetParameters; + if (!userId) { + throw new Error('User id not found in widget parameters'); + } + + if ( + getServerNameFromUserId(userId) === + getServerNameFromUserId(session.userId) + ) { + // Skip connection to local SFU, should be connected already + return; } - if (!isLivekitFocusConfig(focus)) { - this.logger.error( - 'Unable to init focus backend due to unsupported LiveKit configuration', - focus, + const localMemberId = this.statistics.localSession?.memberId; + if (!localMemberId) { + this.logger.warn( + 'Ignore incoming session, local session member id is undefined', ); - throw new Error('Unsupported active focus backend configuration'); + return; + } + + // Establish connection to remote SFU if not connected already + await this.ensurePeerConnectionExists( + session.livekitTransport.livekitServiceUrl, + localMemberId, + ); + } + + private async handleSessionLeft(session: MatrixRtcSession): Promise { + this.logger.log('Left', session.sessionId, session.userId); + this.addSessionStatistics(session.sessionId); + + if (this.statistics.localSession?.sessionId === session.sessionId) { + this.logger.log('Observe own session left, rejoin peer connections'); + + const { + sessionId, + memberId, + livekitTransport: { livekitServiceUrl }, + } = await this.sessionManager.join(this.whiteboardId); + + this.statistics.localSession = { + sessionId, + memberId, + livekitServiceUrl, + }; + this.statisticsSubject.next(cloneDeep(this.statistics)); + + for (const peerConnectionWrapper of this.peerConnections.values()) { + if (peerConnectionWrapper.connection) { + const existingPeerConnection = peerConnectionWrapper.connection; + const connectionId = existingPeerConnection.getConnectionId(); + + this.logger.log( + `Close peer connection to ${connectionId} for session ${session.sessionId}`, + ); + + existingPeerConnection.close(); + + try { + peerConnectionWrapper.connection = await this.createPeerConnection( + memberId, + connectionId, + ); + } catch (e) { + this.logger.error( + `Could not create a peer connection to ${livekitServiceUrl}`, + e, + ); + this.peerConnections.delete(livekitServiceUrl); + } + } + } + } + } + + private async ensurePeerConnectionExists( + livekitServiceUrl: string, + memberId: string, + ): Promise { + let peerConnectionWrapper: PeerConnectionWrapper | undefined = + this.peerConnections.get(livekitServiceUrl); + if (peerConnectionWrapper) { + this.logger.log(`Use existing peer connection to ${livekitServiceUrl}`); + } else { + this.logger.log(`Create peer connection to ${livekitServiceUrl}`); + } + if (!peerConnectionWrapper) { + peerConnectionWrapper = { + connectionId: livekitServiceUrl, + }; + this.peerConnections.set(livekitServiceUrl, peerConnectionWrapper); + + try { + peerConnectionWrapper.connection = await this.createPeerConnection( + memberId, + livekitServiceUrl, + ); + } catch (e) { + this.logger.error( + `Could not create a peer connection to ${livekitServiceUrl}`, + e, + ); + this.peerConnections.delete(livekitServiceUrl); + } } + } + private async createPeerConnection( + memberId: string, + livekitServiceUrl: string, + ): Promise { const widgetApi = await this.widgetApiPromise; - if (!widgetApi.widgetParameters.roomId) { - this.logger.error('Room ID not found in widget parameters'); - throw new Error('Room ID not found in widget parameters'); + + const { userId, roomId, deviceId } = widgetApi.widgetParameters; + if (!userId || !roomId || !deviceId) { + throw new Error('Unexpected widget parameters'); } - // Disconnect before starting a new backend connection - await this.disconnect(); + const openIdToken = await widgetApi.requestOpenIDConnectToken(); - // The LiveKit alias is required to check for room membership when requesting the - // OpenID token and place users in the same LiveKit data exchange room - const activeFocus: LivekitFocus = { - ...focus, - livekit_alias: widgetApi.widgetParameters.roomId, + const newOpenIdToken: IOpenIDCredentials = { + access_token: openIdToken.access_token, + expires_in: openIdToken.expires_in, + matrix_server_name: openIdToken.matrix_server_name, + token_type: openIdToken.token_type, }; - this.sfuConfig = await AutoDiscovery.getSFUConfigWithOpenID( - widgetApi, - activeFocus, + const sfuConfig = await AutoDiscovery.getSFUConfigWithOpenID( + newOpenIdToken, + { + type: 'livekit', + livekit_service_url: livekitServiceUrl, + livekit_alias: roomId, + }, + `net.nordeck.whiteboard#${this.whiteboardId}`, + userId, + deviceId, + memberId, ); - if (!this.sfuConfig) { - this.logger.error('Unable to retrieve LiveKit SFU configuration'); + if (!sfuConfig) { throw new Error('Unable to retrieve LiveKit SFU configuration'); } - const { userId, deviceId } = widgetApi.widgetParameters; - if (!userId || !deviceId) { - this.logger.error('User ID or device ID not found in widget parameters'); - throw new Error('User ID or device ID not found in widget parameters'); - } - - const sessionId = `_${userId}_${deviceId}`; - const session: Session = { sessionId: sessionId, userId: userId }; - - this.logger.debug( - 'Creating peer connection with focus config', - JSON.stringify(this.sfuConfig), - 'for session', - session.sessionId, + const peerConnection = new MatrixRtcPeerConnection( + livekitServiceUrl, + sfuConfig.url, + sfuConfig.jwt, + (sessionId) => { + const session = this.statistics.sessions[sessionId]; + return session ? session.userId : undefined; + }, ); - const peerConnection = new MatrixRtcPeerConnection(session, this.sfuConfig); - this.peerConnections.push(peerConnection); - - peerConnection.observeConnectionState().subscribe(async (state) => { - this.logger.debug('Peer connection state changed:', state); - - switch (state) { - case ConnectionState.Connected: - await this.connect(); - break; - case ConnectionState.Disconnected: - await this.disconnect(); - break; + this.initPeerConnectionStatistics(peerConnection); - case ConnectionState.Connecting: - case ConnectionState.Reconnecting: - case ConnectionState.SignalReconnecting: - break; - - default: - break; - } - }); + return peerConnection; + } + private initPeerConnectionStatistics(peerConnection: PeerConnection): void { peerConnection.observeMessages().subscribe((m) => { return this.messagesSubject.next(m); }); @@ -324,33 +411,15 @@ export class MatrixRtcCommunicationChannel implements CommunicationChannel { this.statisticsSubject.next(cloneDeep(this.statistics)); } - private addSessionStatistics(session: SessionState) { - if (!this.statistics.sessions) { - this.statistics.sessions = []; - } - - // Find the index of the session if it already exists - const existingSessionIndex = this.statistics.sessions.findIndex( - (existingSession) => existingSession.sessionId === session.sessionId, - ); - - // If session exists, replace it with the new session - if (existingSessionIndex !== -1) { - this.statistics.sessions[existingSessionIndex] = session; + private addSessionStatistics(sessionId: string, session?: MatrixRtcSession) { + if (!session) { + delete this.statistics.sessions[sessionId]; } else { - // If session does not exist, add it - this.statistics.sessions.push(session); - } - } - - private removeSessionStatistics(session: SessionState) { - if (!this.statistics.sessions) { - return; + this.statistics.sessions[sessionId] = { + userId: session.userId, + }; } - // Remove the session from the list - this.statistics.sessions = this.statistics.sessions.filter( - (s) => s.sessionId !== session.sessionId, - ); + this.statisticsSubject.next(cloneDeep(this.statistics)); } } diff --git a/packages/react-sdk/src/state/communication/types.ts b/packages/react-sdk/src/state/communication/types.ts index 078b3c4ab..d197e8bac 100644 --- a/packages/react-sdk/src/state/communication/types.ts +++ b/packages/react-sdk/src/state/communication/types.ts @@ -15,30 +15,48 @@ */ import { Observable } from 'rxjs'; -import { Message, PeerConnectionStatistics } from './connection'; -import { SessionState } from './discovery/sessionManagerImpl'; +import { + Message, + MessageOptions, + PeerConnectionStatistics, +} from './connection'; export type { Message, PeerConnectionStatistics } from './connection'; export type CommunicationChannelStatistics = { - /** The local session id, if connected. */ - localSessionId?: string; + /** The local session, if connected. */ + localSession?: { + /** Session id */ + sessionId: string; + /** Member id, if Matrix RTC session */ + memberId?: string; + /** Livekit service URL, if Matrix RTC session */ + livekitServiceUrl?: string; + }; /** Statistics for each peer connection, indexed by the connection id. */ peerConnections: Record; - /**All sessions */ - sessions?: SessionState[]; + /** Sessions, indexed by session id */ + sessions: Record; +}; + +export type SessionStatistics = { + userId: string; }; export function emptyCommunicationChannelStatistics(): CommunicationChannelStatistics { return { peerConnections: {}, - sessions: [], + sessions: {}, }; } export type CommunicationChannel = { /** Sends a message to all connected peers. */ - broadcastMessage(type: string, content: T): void; + broadcastMessage( + type: string, + content: T, + options?: MessageOptions, + ): void; /** Observe messages from all connected peers. */ observeMessages(): Observable; diff --git a/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.test.ts b/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.test.ts index 2b937f1ca..5b2ec41b9 100644 --- a/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.test.ts +++ b/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.test.ts @@ -42,7 +42,6 @@ import { WebRtcPeerConnection, } from './connection'; import { Session, SessionManager } from './discovery'; -import { SessionState } from './discovery/sessionManagerImpl'; import { SignalingChannel } from './signaling'; import { WebRtcCommunicationChannel } from './webRtcCommunicationChannel'; @@ -85,7 +84,6 @@ describe('WebRtcCommunicationChannel', () => { let signalingChannel: SignalingChannel; let peerConnection: Mocked; let channel: WebRtcCommunicationChannel; - let sessionSubject: Subject; let joinedSubject: Subject; let leftSubject: Subject; let statisticsSubject: Subject; @@ -104,13 +102,11 @@ describe('WebRtcCommunicationChannel', () => { sendDescription: vi.fn().mockImplementation(async () => {}), }; - sessionSubject = new Subject(); joinedSubject = new Subject(); leftSubject = new Subject(); sessionManager = { getSessionId: vi.fn(() => currentSessionId), getSessions: vi.fn().mockReturnValue([]), - observeSession: vi.fn().mockReturnValue(sessionSubject), observeSessionJoined: vi.fn().mockReturnValue(joinedSubject), observeSessionLeft: vi.fn().mockReturnValue(leftSubject), join: vi.fn().mockImplementation(async () => { @@ -163,9 +159,15 @@ describe('WebRtcCommunicationChannel', () => { expect(peerConnection.close).toHaveBeenCalled(); expect(channel.getStatistics()).toEqual({ - localSessionId: 'session-id', + localSession: { + sessionId: 'session-id', + }, peerConnections: {}, - sessions: [], + sessions: { + 'another-session-id': { + userId: '@another-user-id:example.com', + }, + }, }); }); @@ -241,18 +243,30 @@ describe('WebRtcCommunicationChannel', () => { statisticsSubject.next(peerConnectionStatistics); expect(channel.getStatistics()).toEqual({ - localSessionId: 'session-id', + localSession: { + sessionId: 'session-id', + }, peerConnections: { 'connection-id': peerConnectionStatistics, }, - sessions: [], + sessions: { + 'another-session-id': { + userId: '@another-user-id:example.com', + }, + }, }); await expect(statisticsPromise).resolves.toEqual({ - localSessionId: 'session-id', + localSession: { + sessionId: 'session-id', + }, peerConnections: { 'connection-id': peerConnectionStatistics, }, - sessions: [], + sessions: { + 'another-session-id': { + userId: '@another-user-id:example.com', + }, + }, }); }); @@ -325,7 +339,9 @@ describe('WebRtcCommunicationChannel', () => { it('should add peer connections for joined sessions', async () => { expect(sessionManager.join).toHaveBeenCalledWith('whiteboard-id'); expect(channel.getStatistics()).toMatchObject({ - localSessionId: 'session-id', + localSession: { + sessionId: 'session-id', + }, }); joinedSubject.next(anotherSession); diff --git a/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.ts b/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.ts index e0a481e16..7121feb77 100644 --- a/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.ts +++ b/packages/react-sdk/src/state/communication/webRtcCommunicationChannel.ts @@ -41,7 +41,6 @@ import { WebRtcPeerConnection, } from './connection'; import { Session, SessionManager } from './discovery'; -import { SessionState } from './discovery/sessionManagerImpl'; import { SignalingChannel } from './signaling'; import { CommunicationChannel, @@ -85,10 +84,10 @@ export class WebRtcCommunicationChannel implements CommunicationChannel { }); this.sessionManager - .observeSession() + .observeSessionJoined() .pipe(takeUntil(this.destroySubject)) .subscribe((session) => { - this.addSessionStatistics(session); + this.addSessionStatistics(session.sessionId, session); }); this.sessionManager @@ -188,7 +187,7 @@ export class WebRtcCommunicationChannel implements CommunicationChannel { } private async connect() { - if (this.statistics.localSessionId) { + if (this.statistics.localSession) { this.logger.log('Communication channel is already open'); return; } @@ -196,7 +195,9 @@ export class WebRtcCommunicationChannel implements CommunicationChannel { this.logger.log('Connecting communication channel'); const { sessionId } = await this.sessionManager.join(this.whiteboardId); - this.statistics.localSessionId = sessionId; + this.statistics.localSession = { + sessionId, + }; this.statisticsSubject.next(cloneDeep(this.statistics)); } @@ -205,7 +206,7 @@ export class WebRtcCommunicationChannel implements CommunicationChannel { await this.sessionManager.leave(); - this.statistics.localSessionId = undefined; + this.statistics.localSession = undefined; this.statisticsSubject.next(cloneDeep(this.statistics)); } @@ -288,25 +289,15 @@ export class WebRtcCommunicationChannel implements CommunicationChannel { this.statisticsSubject.next(cloneDeep(this.statistics)); } - private addSessionStatistics(session: SessionState) { - if (!this.statistics.sessions) { - this.statistics.sessions = []; - } - - // Find the index of the session if it already exists - const existingSessionIndex = this.statistics.sessions.findIndex( - (existingSession) => - existingSession.sessionId === session.sessionId && - existingSession.userId === session.userId && - existingSession.whiteboardId === session.whiteboardId, - ); - - // If session exists, replace it with the new session - if (existingSessionIndex !== -1) { - this.statistics.sessions[existingSessionIndex] = session; + private addSessionStatistics(sessionId: string, session?: Session) { + if (!session) { + delete this.statistics.sessions[sessionId]; } else { - // If session does not exist, add it - this.statistics.sessions.push(session); + this.statistics.sessions[sessionId] = { + userId: session.userId, + }; } + + this.statisticsSubject.next(cloneDeep(this.statistics)); } } diff --git a/packages/react-sdk/src/state/presentationManagerImpl.test.ts b/packages/react-sdk/src/state/presentationManagerImpl.test.ts index 5c33f8bbe..ddedd0b35 100644 --- a/packages/react-sdk/src/state/presentationManagerImpl.test.ts +++ b/packages/react-sdk/src/state/presentationManagerImpl.test.ts @@ -50,7 +50,9 @@ describe('presentationManager', () => { ); communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-bob:example.com', @@ -63,6 +65,7 @@ describe('presentationManager', () => { 'session-1', ), }, + sessions: {}, }; observeCommunicationStatisticsSubject = new Subject(); @@ -153,6 +156,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-0' } }, + { + reliable: true, + }, ); expect(whiteboardInstance.setActiveSlideId).not.toHaveBeenCalled(); }); @@ -175,6 +181,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(whiteboardInstance.setActiveSlideId).not.toHaveBeenCalled(); expect(whiteboardInstance.setActiveFrameElementId).toHaveBeenCalledWith( @@ -184,9 +193,11 @@ describe('presentationManager', () => { it('should start the presentation for active frame in infinite canvas mode in matrix rtc mode', async () => { communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: {}, - sessions: [], + sessions: {}, }; vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => { @@ -213,6 +224,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(whiteboardInstance.setActiveSlideId).not.toHaveBeenCalled(); expect(whiteboardInstance.setActiveFrameElementId).toHaveBeenCalledWith( @@ -236,6 +250,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_slide', { view: undefined }, + { + reliable: true, + }, ); expect(whiteboardInstance.setActiveSlideId).not.toHaveBeenCalled(); }); @@ -260,6 +277,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_frame', { view: undefined }, + { + reliable: true, + }, ); expect(whiteboardInstance.setActiveFrameElementId).toHaveBeenCalledWith( undefined, @@ -268,9 +288,11 @@ describe('presentationManager', () => { it('should stop the presentation for active frame in infinite canvas mode in matrix rtc mode', async () => { communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: {}, - sessions: [], + sessions: {}, }; vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => { @@ -299,6 +321,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_frame', { view: undefined }, + { + reliable: true, + }, ); expect(whiteboardInstance.setActiveFrameElementId).toHaveBeenCalledWith( undefined, @@ -366,16 +391,15 @@ describe('presentationManager', () => { it('should accept presentation start of a different user in infinite canvas mode in matrix rtc mode', async () => { communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: {}, - sessions: [ - { + sessions: { + 'session-bob': { userId: '@user-bob:example.com', - expiresTs: 2525599520143, - sessionId: '_@user-bob:example.com_6jjRubIAWv', - whiteboardId: 'whiteboard-id', }, - ], + }, }; vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => { @@ -395,7 +419,7 @@ describe('presentationManager', () => { messageSubject.next({ senderUserId: '@user-bob:example.com', - senderSessionId: '_@user-bob:example.com_6jjRubIAWv', + senderSessionId: 'session-bob', type: 'net.nordeck.whiteboard.present_frame', content: { view: { isEditMode: false, frameId: 'frame-0' }, @@ -497,16 +521,15 @@ describe('presentationManager', () => { it('should accept presentation stop of a different user in infinite canvas mode in matrix rtc mode', async () => { communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: {}, - sessions: [ - { + sessions: { + 'session-bob': { userId: '@user-bob:example.com', - expiresTs: 2525599520143, - sessionId: '_@user-bob:example.com_6jjRubIAWv', - whiteboardId: 'whiteboard-id', }, - ], + }, }; vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => { @@ -522,7 +545,7 @@ describe('presentationManager', () => { messageSubject.next({ senderUserId: '@user-bob:example.com', - senderSessionId: '_@user-bob:example.com_6jjRubIAWv', + senderSessionId: 'session-bob', type: 'net.nordeck.whiteboard.present_frame', content: { view: { isEditMode: false, frameId: 'frame-0' }, @@ -538,7 +561,7 @@ describe('presentationManager', () => { messageSubject.next({ senderUserId: '@user-bob:example.com', - senderSessionId: '_@user-bob:example.com_6jjRubIAWv', + senderSessionId: 'session-bob', type: 'net.nordeck.whiteboard.present_frame', content: { view: undefined }, }); @@ -644,7 +667,9 @@ describe('presentationManager', () => { ); communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-bob:example.com', @@ -652,6 +677,7 @@ describe('presentationManager', () => { 'session-0', ), }, + sessions: {}, }; observeCommunicationStatisticsSubject.next(communicationStatistics); @@ -677,16 +703,25 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-1' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 3, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-2' } }, + { + reliable: true, + }, ); }); @@ -705,16 +740,25 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-1' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 3, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-2' } }, + { + reliable: true, + }, ); }); @@ -722,7 +766,9 @@ describe('presentationManager', () => { presentationManager.startPresentation(); communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-bob:example.com', @@ -740,6 +786,7 @@ describe('presentationManager', () => { 'session-3', ), }, + sessions: {}, }; observeCommunicationStatisticsSubject.next(communicationStatistics); @@ -748,6 +795,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-0' } }, + { + reliable: true, + }, ); }); @@ -759,7 +809,9 @@ describe('presentationManager', () => { presentationManager.startPresentation('frame-0'); communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-bob:example.com', @@ -777,6 +829,7 @@ describe('presentationManager', () => { 'session-3', ), }, + sessions: {}, }; observeCommunicationStatisticsSubject.next(communicationStatistics); @@ -785,14 +838,19 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); }); it('should broadcast the active frame when a new session is connected in infinite canvas mode in matrix rtc mode', async () => { communicationStatistics = { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: {}, - sessions: [], + sessions: {}, }; vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => { @@ -809,16 +867,22 @@ describe('presentationManager', () => { presentationManager.startPresentation('frame-0'); communicationStatistics = { - localSessionId: 'own', - peerConnections: {}, - sessions: [ - { + localSession: { + sessionId: 'own', + }, + peerConnections: { + 'https://livekit-jwt.example.com': mockPeerConnectionStatistics( + '@user:example.com', + 'connected', + 'other', + ['session-bob'], + ), + }, + sessions: { + 'session-bob': { userId: '@user-bob:example.com', - expiresTs: 2525599520143, - sessionId: '_@user-bob:example.com_6jjRubIAWv', - whiteboardId: 'whiteboard-id', }, - ], + }, }; observeCommunicationStatisticsSubject.next(communicationStatistics); @@ -827,6 +891,9 @@ describe('presentationManager', () => { expect(communicationChannel.broadcastMessage).toHaveBeenCalledWith( 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); }); @@ -835,8 +902,11 @@ describe('presentationManager', () => { observeActiveSlideIdSubject.next('slide-1'); communicationStatistics = { - localSessionId: 'own-new', + localSession: { + sessionId: 'own-new', + }, peerConnections: {}, + sessions: {}, }; observeCommunicationStatisticsSubject.next(communicationStatistics); @@ -847,11 +917,17 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-1' } }, + { + reliable: true, + }, ); }); @@ -864,8 +940,11 @@ describe('presentationManager', () => { observeActiveFrameElementIdSubject.next('frame-1'); communicationStatistics = { - localSessionId: 'own-new', + localSession: { + sessionId: 'own-new', + }, peerConnections: {}, + sessions: {}, }; observeCommunicationStatisticsSubject.next(communicationStatistics); @@ -876,11 +955,17 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-1' } }, + { + reliable: true, + }, ); }); @@ -934,16 +1019,25 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: true, slideId: 'slide-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 3, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-0' } }, + { + reliable: true, + }, ); }); @@ -971,16 +1065,25 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: true, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 3, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); }); @@ -1004,16 +1107,25 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: true, slideId: 'slide-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 3, 'net.nordeck.whiteboard.present_slide', { view: { isEditMode: false, slideId: 'slide-1' } }, + { + reliable: true, + }, ); }); @@ -1041,16 +1153,25 @@ describe('presentationManager', () => { 1, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 2, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: true, frameId: 'frame-0' } }, + { + reliable: true, + }, ); expect(communicationChannel.broadcastMessage).toHaveBeenNthCalledWith( 3, 'net.nordeck.whiteboard.present_frame', { view: { isEditMode: false, frameId: 'frame-1' } }, + { + reliable: true, + }, ); }); diff --git a/packages/react-sdk/src/state/presentationManagerImpl.ts b/packages/react-sdk/src/state/presentationManagerImpl.ts index 9f39c236f..17d645d6f 100644 --- a/packages/react-sdk/src/state/presentationManagerImpl.ts +++ b/packages/react-sdk/src/state/presentationManagerImpl.ts @@ -31,7 +31,6 @@ import { takeWhile, } from 'rxjs'; import { isDefined, isInfiniteCanvasMode, isMatrixRtcMode } from '../lib'; -import { isNotExpired } from '../model'; import { CommunicationChannel, isValidPresentFrameMessage, @@ -72,18 +71,13 @@ export class PresentationManagerImpl implements PresentationManager { filter( (statistics) => this.currentPresenterSessionId !== undefined && - this.currentPresenterSessionId === statistics.localSessionId, + this.currentPresenterSessionId === + statistics.localSession?.sessionId, ), map((statistics) => { if (isMatrixRtcMode()) { - return ( - statistics.sessions - ?.filter( - (session) => - session.sessionId !== statistics.localSessionId && - isNotExpired(session), - ) - .map((session) => session.sessionId) ?? [] + return Object.values(statistics.peerConnections).flatMap( + (peer) => peer.remoteParticipantIdentities, ); } else { return Object.values(statistics.peerConnections) @@ -110,6 +104,9 @@ export class PresentationManagerImpl implements PresentationManager { frameId: activeFrameId, }, }, + { + reliable: true, + }, ); } } else { @@ -124,6 +121,9 @@ export class PresentationManagerImpl implements PresentationManager { slideId: activeSlideId, }, }, + { + reliable: true, + }, ); } } @@ -187,7 +187,7 @@ export class PresentationManagerImpl implements PresentationManager { startPresentation(frameElementId?: string): void { const localSessionId = - this.communicationChannel.getStatistics().localSessionId; + this.communicationChannel.getStatistics().localSession?.sessionId; if (!localSessionId) { return; @@ -212,7 +212,7 @@ export class PresentationManagerImpl implements PresentationManager { () => this.currentPresenterSessionId !== undefined && this.currentPresenterSessionId === - this.communicationChannel.getStatistics().localSessionId, + this.communicationChannel.getStatistics().localSession?.sessionId, ), ) .subscribe((slideOrFrameId) => { @@ -222,11 +222,17 @@ export class PresentationManagerImpl implements PresentationManager { this.communicationChannel.broadcastMessage( PRESENT_FRAME_MESSAGE, { view: { isEditMode: false, frameId: slideOrFrameId } }, + { + reliable: true, + }, ); } else { this.communicationChannel.broadcastMessage( PRESENT_SLIDE_MESSAGE, { view: { isEditMode: false, slideId: slideOrFrameId } }, + { + reliable: true, + }, ); } }); @@ -242,11 +248,17 @@ export class PresentationManagerImpl implements PresentationManager { this.communicationChannel.broadcastMessage( PRESENT_FRAME_MESSAGE, { view: undefined }, + { + reliable: true, + }, ); } else { this.communicationChannel.broadcastMessage( PRESENT_SLIDE_MESSAGE, { view: undefined }, + { + reliable: true, + }, ); } this.activeSlidePublisher?.unsubscribe(); @@ -265,17 +277,16 @@ export class PresentationManagerImpl implements PresentationManager { map(([statistics, presenterSessionId, isEditMode]): PresentationState => { if ( presenterSessionId && - presenterSessionId === statistics.localSessionId + presenterSessionId === statistics.localSession?.sessionId ) { return { type: 'presenting', isEditMode }; } let presenterUserId: string | undefined = undefined; if (isMatrixRtcMode()) { - const presenterSession = statistics.sessions?.find( - (session) => - session.sessionId === presenterSessionId && isNotExpired(session), - ); + const presenterSession = presenterSessionId + ? statistics.sessions[presenterSessionId] + : undefined; if (presenterSession) { presenterUserId = presenterSession.userId; @@ -319,11 +330,17 @@ export class PresentationManagerImpl implements PresentationManager { this.communicationChannel.broadcastMessage( PRESENT_FRAME_MESSAGE, { view: { isEditMode, frameId: slideOrFrameId } }, + { + reliable: true, + }, ); } else { this.communicationChannel.broadcastMessage( PRESENT_SLIDE_MESSAGE, { view: { isEditMode, slideId: slideOrFrameId } }, + { + reliable: true, + }, ); } } diff --git a/packages/react-sdk/src/state/useActiveWhiteboardInstance.test.tsx b/packages/react-sdk/src/state/useActiveWhiteboardInstance.test.tsx index 08fad3a8c..32867bf8e 100644 --- a/packages/react-sdk/src/state/useActiveWhiteboardInstance.test.tsx +++ b/packages/react-sdk/src/state/useActiveWhiteboardInstance.test.tsx @@ -136,13 +136,16 @@ describe('useActiveWhiteboardInstanceStatistics', () => { expect(result.current).toEqual({ communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-alice:example.com', 'connected', ), }, + sessions: {}, }, }); }); @@ -166,19 +169,28 @@ describe('useActiveWhiteboardInstanceStatistics', () => { expect(result.current).toEqual({ communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-alice:example.com', 'connected', ), }, + sessions: {}, }, }); act(() => { const whiteboardStatistics = { - communicationChannel: { localSessionId: 'own', peerConnections: {} }, + communicationChannel: { + localSession: { + sessionId: 'own', + }, + peerConnections: {}, + sessions: {}, + }, document: { contentSizeInBytes: 0, documentSizeInBytes: 0, @@ -192,7 +204,13 @@ describe('useActiveWhiteboardInstanceStatistics', () => { }); expect(result.current).toEqual({ - communicationChannel: { localSessionId: 'own', peerConnections: {} }, + communicationChannel: { + localSession: { + sessionId: 'own', + }, + peerConnections: {}, + sessions: {}, + }, document: { contentSizeInBytes: 0, documentSizeInBytes: 0, diff --git a/packages/react-sdk/src/state/useActiveWhiteboardMembers.test.tsx b/packages/react-sdk/src/state/useActiveWhiteboardMembers.test.tsx index 134fa5885..6b060eaa1 100644 --- a/packages/react-sdk/src/state/useActiveWhiteboardMembers.test.tsx +++ b/packages/react-sdk/src/state/useActiveWhiteboardMembers.test.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { getEnvironment } from '@matrix-widget-toolkit/mui'; import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; import { renderHook } from '@testing-library/react'; import { act, ComponentType, PropsWithChildren } from 'react'; @@ -27,7 +28,6 @@ import { Mocked, vi, } from 'vitest'; -import * as constants from '../components/Whiteboard/constants'; import { mockPeerConnectionStatistics, mockWhiteboardManager, @@ -36,11 +36,22 @@ import { import { WhiteboardManager, WhiteboardStatistics } from './types'; import { useActiveWhiteboardMembers } from './useActiveWhiteboardMembers'; +vi.mock('@matrix-widget-toolkit/mui', async () => ({ + ...(await vi.importActual( + '@matrix-widget-toolkit/mui', + )), + getEnvironment: vi.fn(), +})); + let widgetApi: MockedWidgetApi; afterEach(() => widgetApi.stop()); beforeEach(() => { + vi.mocked(getEnvironment).mockImplementation( + (_, defaultValue) => defaultValue, + ); + widgetApi = mockWidgetApi(); }); @@ -67,8 +78,11 @@ describe('useActiveWhiteboardMembers', () => { it('should return no members', () => { const statistics = { communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: {}, + sessions: {}, }, document: { contentSizeInBytes: 0, @@ -96,7 +110,9 @@ describe('useActiveWhiteboardMembers', () => { it('should return active members', () => { const statistics = { communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-0:example.com', @@ -121,6 +137,7 @@ describe('useActiveWhiteboardMembers', () => { 'failed', ), }, + sessions: {}, }, document: { contentSizeInBytes: 0, @@ -151,13 +168,16 @@ describe('useActiveWhiteboardMembers', () => { it('should update the active members if the statistics change', () => { let statistics: WhiteboardStatistics = { communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-0:example.com', 'connected', ), }, + sessions: {}, }, document: { contentSizeInBytes: 0, @@ -188,7 +208,9 @@ describe('useActiveWhiteboardMembers', () => { act(() => { statistics = { communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { 'peer-0': mockPeerConnectionStatistics( '@user-0:example.com', @@ -213,6 +235,7 @@ describe('useActiveWhiteboardMembers', () => { 'failed', ), }, + sessions: {}, }, document: { contentSizeInBytes: 0, @@ -235,7 +258,9 @@ describe('useActiveWhiteboardMembers', () => { describe('MatrixRTC mode', () => { beforeEach(() => { - vi.spyOn(constants, 'matrixRtcMode', 'get').mockReturnValue(true); + vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => + name === 'REACT_APP_RTC' ? 'matrixrtc' : defaultValue, + ); }); afterEach(() => { @@ -245,9 +270,11 @@ describe('useActiveWhiteboardMembers', () => { it('should return no members', () => { const statistics = { communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: {}, - sessions: [], + sessions: {}, }, document: { contentSizeInBytes: 0, @@ -273,24 +300,27 @@ describe('useActiveWhiteboardMembers', () => { }); it('should return active members', () => { - const statistics = { + const statistics: WhiteboardStatistics = { communicationChannel: { - localSessionId: 'own', - peerConnections: {}, - sessions: [ - { + localSession: { + sessionId: 'own', + }, + peerConnections: { + 'https://livekit-jwt.example.com': mockPeerConnectionStatistics( + '@user:example.com', + 'connected', + 'other', + ['session-0', 'session-1'], + ), + }, + sessions: { + 'session-0': { userId: '@user-0:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-0', - whiteboardId: 'whiteboard-id', }, - { + 'session-1': { userId: '@user-1:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-1', - whiteboardId: 'whiteboard-id', }, - ], + }, }, document: { contentSizeInBytes: 0, @@ -321,16 +351,22 @@ describe('useActiveWhiteboardMembers', () => { it('should update the active members if the statistics change', () => { let statistics: WhiteboardStatistics = { communicationChannel: { - localSessionId: 'own', - peerConnections: {}, - sessions: [ - { + localSession: { + sessionId: 'own', + }, + peerConnections: { + 'https://livekit-jwt.example.com': mockPeerConnectionStatistics( + '@user:example.com', + 'connected', + 'other', + ['session-0'], + ), + }, + sessions: { + 'session-0': { userId: '@user-0:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-0', - whiteboardId: 'whiteboard-id', }, - ], + }, }, document: { contentSizeInBytes: 0, @@ -361,22 +397,25 @@ describe('useActiveWhiteboardMembers', () => { act(() => { statistics = { communicationChannel: { - localSessionId: 'own', - peerConnections: {}, - sessions: [ - { + localSession: { + sessionId: 'own', + }, + peerConnections: { + 'https://livekit-jwt.example.com': mockPeerConnectionStatistics( + '@user:example.com', + 'connected', + 'other', + ['session-0', 'session-1'], + ), + }, + sessions: { + 'session-0': { userId: '@user-0:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-0', - whiteboardId: 'whiteboard-id', }, - { + 'session-1': { userId: '@user-1:example.com', - expiresTs: Date.now() + 1000, - sessionId: 'session-1', - whiteboardId: 'whiteboard-id', }, - ], + }, }, document: { contentSizeInBytes: 0, diff --git a/packages/react-sdk/src/state/useActiveWhiteboardMembers.ts b/packages/react-sdk/src/state/useActiveWhiteboardMembers.ts index 3a5837898..9a0f25eeb 100644 --- a/packages/react-sdk/src/state/useActiveWhiteboardMembers.ts +++ b/packages/react-sdk/src/state/useActiveWhiteboardMembers.ts @@ -15,7 +15,7 @@ */ import { useMemo } from 'react'; -import { matrixRtcMode } from '../components/Whiteboard'; +import { isMatrixRtcMode } from '../lib'; import { isPeerConnected } from './communication/connection'; import { useActiveWhiteboardInstanceStatistics } from './useActiveWhiteboardInstance'; @@ -29,22 +29,29 @@ export function useActiveWhiteboardMembers(): ActiveWhiteboardMember[] { return useMemo(() => { const activeWhiteboardMembers = new Map(); - if (matrixRtcMode) { - const allRtcMembers = statistics.communicationChannel.sessions - ? statistics.communicationChannel.sessions.filter( - (s) => s.expiresTs > Date.now(), - ) - : undefined; - - allRtcMembers?.forEach((s) => { - activeWhiteboardMembers.set(s.userId, { - userId: s.userId, - }); - }); + if (isMatrixRtcMode()) { + Object.values(statistics.communicationChannel.peerConnections).forEach( + (p) => { + if (p.remoteParticipantIdentities) { + for (const remoteParticipantIdentity of p.remoteParticipantIdentities) { + const session = + statistics.communicationChannel.sessions[ + remoteParticipantIdentity + ]; + if (session) { + const participantUserId = session.userId; + activeWhiteboardMembers.set(participantUserId, { + userId: participantUserId, + }); + } + } + } + }, + ); } else { Object.values(statistics.communicationChannel.peerConnections).forEach( (p) => { - if (isPeerConnected(p)) { + if (p.remoteUserId && isPeerConnected(p)) { activeWhiteboardMembers.set(p.remoteUserId, { userId: p.remoteUserId, }); diff --git a/packages/react-sdk/src/state/useOwnedWhiteboard.test.tsx b/packages/react-sdk/src/state/useOwnedWhiteboard.test.tsx index 7c5136be5..c7acf1650 100644 --- a/packages/react-sdk/src/state/useOwnedWhiteboard.test.tsx +++ b/packages/react-sdk/src/state/useOwnedWhiteboard.test.tsx @@ -15,12 +15,13 @@ */ import { RoomEvent, StateEvent } from '@matrix-widget-toolkit/api'; +import { getEnvironment } from '@matrix-widget-toolkit/mui'; import { WidgetApiMockProvider } from '@matrix-widget-toolkit/react'; import { MockedWidgetApi, mockWidgetApi } from '@matrix-widget-toolkit/testing'; import { renderHook, waitFor } from '@testing-library/react'; import { ComponentType, PropsWithChildren, useState } from 'react'; import { Provider } from 'react-redux'; -import { afterEach, beforeEach, describe, expect, it, test } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, test, vi } from 'vitest'; import { mockDocumentCreate, mockPowerLevelsEvent, @@ -33,11 +34,24 @@ import { import { createStore } from '../store'; import { useOwnedWhiteboard } from './useOwnedWhiteboard'; +vi.mock('@matrix-widget-toolkit/mui', async () => ({ + ...(await vi.importActual( + '@matrix-widget-toolkit/mui', + )), + getEnvironment: vi.fn(), +})); + let widgetApi: MockedWidgetApi; afterEach(() => widgetApi.stop()); -beforeEach(() => (widgetApi = mockWidgetApi())); +beforeEach(() => { + vi.mocked(getEnvironment).mockImplementation( + (_, defaultValue) => defaultValue, + ); + + widgetApi = mockWidgetApi(); +}); describe('useOwnedWhiteboard', () => { let Wrapper: ComponentType>; @@ -248,6 +262,81 @@ describe('useOwnedWhiteboard', () => { }); }); + it('should create a new document, snapshot, whiteboard and slot in Matrix RTC mode', async () => { + vi.mocked(getEnvironment).mockImplementation((name, defaultValue) => + name === 'REACT_APP_RTC' ? 'matrixrtc' : defaultValue, + ); + + widgetApi.mockSendStateEvent(mockPowerLevelsEvent()); + + const { result } = renderHook(() => useOwnedWhiteboard(), { + wrapper: Wrapper, + }); + + await waitFor(() => { + expect(result.current).toEqual({ loading: true }); + }); + + expect(widgetApi.sendStateEvent).not.toHaveBeenCalledWith( + 'm.room.power_levels', + { + events: { 'net.nordeck.whiteboard.sessions': 0 }, + users_default: 100, + }, + ); + + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + 'net.nordeck.whiteboard.document.create', + {}, + ); + + // Expect a snapshot with a chunk + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_DOCUMENT_SNAPSHOT, + expect.anything(), + ); + expect(widgetApi.sendRoomEvent).toHaveBeenCalledWith( + ROOM_EVENT_DOCUMENT_CHUNK, + expect.anything(), + ); + + const documentId = await widgetApi.sendRoomEvent.mock.results[0].value.then( + (e: RoomEvent) => e.event_id, + ); + + expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( + 'net.nordeck.whiteboard', + { documentId }, + { stateKey: 'widget-id' }, + ); + + expect(widgetApi.sendStateEvent).toHaveBeenCalledWith( + 'org.matrix.msc4143.rtc.slot', + { + status: 'open', + application: { + type: 'net.nordeck.whiteboard', + }, + }, + { stateKey: 'net.nordeck.whiteboard#widget-id' }, + ); + + await waitFor(() => { + expect(result.current).toEqual({ + loading: false, + value: { + type: 'whiteboard', + event: mockWhiteboard({ + content: { documentId }, + event_id: expect.any(String), + origin_server_ts: expect.any(Number), + state_key: 'widget-id', + }), + }, + }); + }); + }); + it('should wait for moderator to create a new whiteboard', async () => { widgetApi.mockSendStateEvent( mockPowerLevelsEvent({ diff --git a/packages/react-sdk/src/state/useOwnedWhiteboard.tsx b/packages/react-sdk/src/state/useOwnedWhiteboard.tsx index b026a356d..f0b8b29c5 100644 --- a/packages/react-sdk/src/state/useOwnedWhiteboard.tsx +++ b/packages/react-sdk/src/state/useOwnedWhiteboard.tsx @@ -19,10 +19,10 @@ import { useWidgetApi } from '@matrix-widget-toolkit/react'; import first from 'lodash/first'; import loglevel from 'loglevel'; import { useAsync } from 'react-use'; -import { matrixRtcMode } from '../components/Whiteboard'; -import { isInfiniteCanvasMode } from '../lib'; +import { isInfiniteCanvasMode, isMatrixRtcMode } from '../lib'; import { - STATE_EVENT_RTC_MEMBER, + RtcSlot, + STATE_EVENT_4143_RTC_SLOT, STATE_EVENT_WHITEBOARD_SESSIONS, Whiteboard, } from '../model'; @@ -80,22 +80,21 @@ export function useOwnedWhiteboard(): UseOwnedWhiteboardResponse { error, } = useAsync(async () => { if (canInitializeWhiteboard && !whiteboard && !isLoading && !isError) { - try { - // TODO: We only set the power level once, if it's later changed we can't - // handle it. It would be better to show a UI to a moderator to repair - // the power level setting if it is wrong. - const sessionStateEvent = matrixRtcMode - ? STATE_EVENT_RTC_MEMBER - : STATE_EVENT_WHITEBOARD_SESSIONS; - await patchPowerLevels({ - changes: { - events: { - [sessionStateEvent]: 0, + if (!isMatrixRtcMode()) { + try { + // TODO: We only set the power level once, if it's later changed we can't + // handle it. It would be better to show a UI to a moderator to repair + // the power level setting if it is wrong. + await patchPowerLevels({ + changes: { + events: { + [STATE_EVENT_WHITEBOARD_SESSIONS]: 0, + }, }, - }, - }).unwrap(); - } catch (err) { - loglevel.error('could not configure power level', err); + }).unwrap(); + } catch (err) { + loglevel.error('could not configure power level', err); + } } const documentId = (await createDocument().unwrap()).event.event_id; @@ -113,13 +112,30 @@ export function useOwnedWhiteboard(): UseOwnedWhiteboardResponse { }), ).unwrap(); + const whiteboardId = widgetApi.widgetId; const result = await updateWhiteboard({ - whiteboardId: widgetApi.widgetId, + whiteboardId, content: { documentId, }, }).unwrap(); + if (isMatrixRtcMode()) { + const whiteboardRtcSlot: RtcSlot = { + status: 'open', + application: { + type: 'net.nordeck.whiteboard', + }, + }; + await widgetApi.sendStateEvent( + STATE_EVENT_4143_RTC_SLOT, + whiteboardRtcSlot, + { + stateKey: `net.nordeck.whiteboard#${whiteboardId}`, + }, + ); + } + return result.event; } diff --git a/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts b/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts index f747cdeb2..08f7f860d 100644 --- a/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts +++ b/packages/react-sdk/src/state/whiteboardInstanceImpl.test.ts @@ -109,9 +109,12 @@ describe('WhiteboardInstanceImpl', () => { communicationChannel = { broadcastMessage: vi.fn(), observeMessages: vi.fn().mockReturnValue(messageSubject), - getStatistics: vi - .fn() - .mockReturnValue({ localSessionId: 'own', peerConnections: {} }), + getStatistics: vi.fn().mockReturnValue({ + localSession: { + sessionId: 'own', + }, + peerConnections: {}, + }), observeStatistics: vi .fn() .mockReturnValue(observeCommunicationStatisticsSubject), @@ -205,7 +208,12 @@ describe('WhiteboardInstanceImpl', () => { ); expect(whiteboardInstance.getWhiteboardStatistics()).toEqual({ - communicationChannel: { localSessionId: 'own', peerConnections: {} }, + communicationChannel: { + localSession: { + sessionId: 'own', + }, + peerConnections: {}, + }, }); const statistics = firstValueFrom( @@ -227,14 +235,20 @@ describe('WhiteboardInstanceImpl', () => { }; observeCommunicationStatisticsSubject.next({ - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { key: peerConnectionStatistics }, + sessions: {}, }); expect(await statistics).toEqual({ communicationChannel: { - localSessionId: 'own', + localSession: { + sessionId: 'own', + }, peerConnections: { key: peerConnectionStatistics }, + sessions: {}, }, }); }); @@ -248,7 +262,12 @@ describe('WhiteboardInstanceImpl', () => { ); expect(whiteboardInstance.getWhiteboardStatistics()).toEqual({ - communicationChannel: { localSessionId: 'own', peerConnections: {} }, + communicationChannel: { + localSession: { + sessionId: 'own', + }, + peerConnections: {}, + }, }); const statistics = firstValueFrom( @@ -261,7 +280,12 @@ describe('WhiteboardInstanceImpl', () => { }); expect(await statistics).toEqual({ - communicationChannel: { localSessionId: 'own', peerConnections: {} }, + communicationChannel: { + localSession: { + sessionId: 'own', + }, + peerConnections: {}, + }, document: { contentSizeInBytes: 100, documentSizeInBytes: 200, diff --git a/packages/react-sdk/src/state/whiteboardInstanceImpl.ts b/packages/react-sdk/src/state/whiteboardInstanceImpl.ts index 99048cfa0..b8b244e7e 100644 --- a/packages/react-sdk/src/state/whiteboardInstanceImpl.ts +++ b/packages/react-sdk/src/state/whiteboardInstanceImpl.ts @@ -32,21 +32,20 @@ import { takeUntil, tap, } from 'rxjs'; -import { matrixRtcMode } from '../components/Whiteboard'; -import { isInfiniteCanvasMode } from '../lib'; +import { isInfiniteCanvasMode, isMatrixRtcMode } from '../lib'; import { Whiteboard } from '../model'; import { StoreType } from '../store'; import { CommunicationChannel, FOCUS_ON_MESSAGE, FocusOn, + MatrixRtcCommunicationChannel, + MatrixRtcSession, SessionManager, SignalingChannel, WebRtcCommunicationChannel, isValidFocusOnMessage, } from './communication'; -import { isMatrixRtcSessionManager } from './communication/discovery/types'; -import { MatrixRtcCommunicationChannel } from './communication/matrixRtcCommunicationChannel'; import { emptyCommunicationChannelStatistics } from './communication/types'; import { WhiteboardDocument, @@ -216,15 +215,12 @@ export class WhiteboardInstanceImpl implements WhiteboardInstance { ): WhiteboardInstanceImpl { const enableObserveVisibilityStateSubject = new BehaviorSubject(true); + const matrixRtcMode = isMatrixRtcMode(); let communicationChannel: CommunicationChannel | undefined = undefined; - if ( - matrixRtcMode && - sessionManager && - isMatrixRtcSessionManager(sessionManager) - ) { + if (matrixRtcMode && sessionManager) { communicationChannel = new MatrixRtcCommunicationChannel( widgetApiPromise, - sessionManager, + sessionManager as SessionManager, whiteboardEvent.state_key, enableObserveVisibilityStateSubject, ); diff --git a/packages/react-sdk/src/state/whiteboardManagerImpl.ts b/packages/react-sdk/src/state/whiteboardManagerImpl.ts index 26576ec76..e3e6f4e17 100644 --- a/packages/react-sdk/src/state/whiteboardManagerImpl.ts +++ b/packages/react-sdk/src/state/whiteboardManagerImpl.ts @@ -16,7 +16,7 @@ import { StateEvent, WidgetApi } from '@matrix-widget-toolkit/api'; import { BehaviorSubject } from 'rxjs'; -import { matrixRtcMode } from '../components/Whiteboard'; +import { isMatrixRtcMode } from '../lib'; import { Whiteboard } from '../model'; import { StoreType } from '../store'; import { @@ -91,16 +91,18 @@ export function createWhiteboardManager( let sessionManager: SessionManager | undefined; let signalingChannel: SignalingChannel | undefined; + const matrixRtcMode = isMatrixRtcMode(); if (!disableRtc) { - // Initialize signaling channel only for P2P WebRTC mode if (!matrixRtcMode) { signalingChannel = new ToDeviceMessageSignalingChannel(widgetApiPromise); } // Initialize session manager based on RTC mode - sessionManager = matrixRtcMode - ? new MatrixRtcSessionManagerImpl(widgetApiPromise) - : new SessionManagerImpl(widgetApiPromise); + if (matrixRtcMode) { + sessionManager = new MatrixRtcSessionManagerImpl(widgetApiPromise); + } else { + sessionManager = new SessionManagerImpl(widgetApiPromise); + } } return new WhiteboardManagerImpl( diff --git a/packages/react-sdk/src/state/whiteboardSlideInstanceImpl.test.ts b/packages/react-sdk/src/state/whiteboardSlideInstanceImpl.test.ts index 55eeff68e..0d8bd2844 100644 --- a/packages/react-sdk/src/state/whiteboardSlideInstanceImpl.test.ts +++ b/packages/react-sdk/src/state/whiteboardSlideInstanceImpl.test.ts @@ -80,9 +80,12 @@ describe('WhiteboardSlideInstanceImpl', () => { communicationChannel = { broadcastMessage: vi.fn(), observeMessages: vi.fn().mockReturnValue(messageChannel), - getStatistics: vi - .fn() - .mockReturnValue({ localSessionId: 'own', peerConnections: {} }), + getStatistics: vi.fn().mockReturnValue({ + localSession: { + sessionId: 'own', + }, + peerConnections: {}, + }), observeStatistics: vi.fn().mockReturnValue(observeStatisticsSubject), destroy: vi.fn(), }; diff --git a/packages/react-sdk/src/store/api/useUserDetails.ts b/packages/react-sdk/src/store/api/useUserDetails.ts index 2f733242c..e95416953 100644 --- a/packages/react-sdk/src/store/api/useUserDetails.ts +++ b/packages/react-sdk/src/store/api/useUserDetails.ts @@ -16,7 +16,6 @@ import { getRoomMemberDisplayName } from '@matrix-widget-toolkit/api'; import { useCallback } from 'react'; -import { normalizeMatrixUserId } from '../../lib/matrixRtcMode'; import { selectRoomMember, selectRoomMembers, @@ -36,7 +35,6 @@ export function useUserDetails(): UseUserDetailsReturn { const getUserDisplayName = useCallback( (userId: string) => { - userId = normalizeMatrixUserId(userId); const memberEvent = roomMembers ? selectRoomMember(roomMembers, userId) : undefined; diff --git a/packages/react-sdk/src/store/api/whiteboardApi.ts b/packages/react-sdk/src/store/api/whiteboardApi.ts index be1f1f1e0..18411911c 100644 --- a/packages/react-sdk/src/store/api/whiteboardApi.ts +++ b/packages/react-sdk/src/store/api/whiteboardApi.ts @@ -32,7 +32,10 @@ import { import { ThunkExtraArgument } from '../store'; import { baseApi } from './baseApi'; -const whiteboardsEntityAdapter = createEntityAdapter({ +const whiteboardsEntityAdapter = createEntityAdapter< + StateEvent, + string +>({ selectId: (event: StateEvent) => event.state_key, sortComparer: compareOriginServerTS, }); diff --git a/yarn.lock b/yarn.lock index 767751225..2ff8e9815 100644 --- a/yarn.lock +++ b/yarn.lock @@ -466,261 +466,131 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz#82b74f92aa78d720b714162939fb248c90addf53" - integrity sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg== - "@esbuild/aix-ppc64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== -"@esbuild/android-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" - integrity sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ== - "@esbuild/android-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== -"@esbuild/android-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" - integrity sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ== - "@esbuild/android-arm@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== -"@esbuild/android-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" - integrity sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg== - "@esbuild/android-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== -"@esbuild/darwin-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" - integrity sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw== - "@esbuild/darwin-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== -"@esbuild/darwin-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" - integrity sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ== - "@esbuild/darwin-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== -"@esbuild/freebsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" - integrity sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w== - "@esbuild/freebsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== -"@esbuild/freebsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" - integrity sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ== - "@esbuild/freebsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== -"@esbuild/linux-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" - integrity sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A== - "@esbuild/linux-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== -"@esbuild/linux-arm@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" - integrity sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA== - "@esbuild/linux-arm@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== -"@esbuild/linux-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" - integrity sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg== - "@esbuild/linux-ia32@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== -"@esbuild/linux-loong64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" - integrity sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q== - "@esbuild/linux-loong64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== -"@esbuild/linux-mips64el@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" - integrity sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw== - "@esbuild/linux-mips64el@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== -"@esbuild/linux-ppc64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" - integrity sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ== - "@esbuild/linux-ppc64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== -"@esbuild/linux-riscv64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" - integrity sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ== - "@esbuild/linux-riscv64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== -"@esbuild/linux-s390x@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" - integrity sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw== - "@esbuild/linux-s390x@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== -"@esbuild/linux-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" - integrity sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA== - "@esbuild/linux-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== -"@esbuild/netbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" - integrity sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w== - "@esbuild/netbsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== -"@esbuild/netbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" - integrity sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw== - "@esbuild/netbsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== -"@esbuild/openbsd-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" - integrity sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A== - "@esbuild/openbsd-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== -"@esbuild/openbsd-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" - integrity sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg== - "@esbuild/openbsd-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== -"@esbuild/openharmony-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" - integrity sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw== - "@esbuild/openharmony-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== -"@esbuild/sunos-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" - integrity sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA== - "@esbuild/sunos-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== -"@esbuild/win32-arm64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" - integrity sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA== - "@esbuild/win32-arm64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== -"@esbuild/win32-ia32@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" - integrity sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw== - "@esbuild/win32-ia32@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== -"@esbuild/win32-x64@0.27.7": - version "0.27.7" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" - integrity sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg== - "@esbuild/win32-x64@0.28.1": version "0.28.1" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" @@ -866,10 +736,10 @@ resolved "https://registry.yarnpkg.com/@foliojs-fork/restructure/-/restructure-2.0.2.tgz#73759aba2aff1da87b7c4554e6839c70d43c92b4" integrity sha512-59SgoZ3EXbkfSX7b63tsou/SDGzwUEK6MuB5sKqgVK1/XE0fxmpsOb9DQI8LXW3KfGnAjImCGhhEb7uPPAUVNA== -"@fontsource/inter@^5.1.1": - version "5.2.8" - resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.2.8.tgz#10c95d877d972c7de5bd4592309d42fb6a5e1a5b" - integrity sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg== +"@fontsource/inter@^5.2.8": + version "5.3.0" + resolved "https://registry.yarnpkg.com/@fontsource/inter/-/inter-5.3.0.tgz#866278dc2357659f8be1755c8b67f29d3697b440" + integrity sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g== "@fontsource/noto-emoji@^5.2.11": version "5.2.11" @@ -1122,10 +992,10 @@ resolved "https://registry.yarnpkg.com/@livekit/mutex/-/mutex-1.1.1.tgz#72492b611d55be8130ba2271b7a436d94b1bc6d4" integrity sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw== -"@livekit/protocol@1.45.8": - version "1.45.8" - resolved "https://registry.yarnpkg.com/@livekit/protocol/-/protocol-1.45.8.tgz#8314925fcd285f3fe5e3cc3b38110dbc4ed77d8f" - integrity sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA== +"@livekit/protocol@1.50.4": + version "1.50.4" + resolved "https://registry.yarnpkg.com/@livekit/protocol/-/protocol-1.50.4.tgz#31495741dd786924b8ba6e190dd2a6e5d3711c51" + integrity sha512-L1uggNQAqyY21smQY8AllyOYbcv9Me9TaxwuLytL1R8ck9nbYPmQLNwEDi3pOFGAMa5F8I2nUi2Jc59W5awxlA== dependencies: "@bufbuild/protobuf" "^1.10.0" @@ -1151,54 +1021,54 @@ globby "^11.0.0" read-yaml-file "^1.1.0" -"@matrix-widget-toolkit/api@5.0.3", "@matrix-widget-toolkit/api@^5.0.3": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/api/-/api-5.0.3.tgz#e8757aa6c432a22c61c449f6ad963b65c7433d1b" - integrity sha512-9ceQPnqRuuZVYcWHXSquWIdIk5yAh2q1J5chyI2H9D3cFJZcZMa+g/XY9sBAGl/gxpgCr7qWJgrfUNPjHImEew== +"@matrix-widget-toolkit/api@5.1.0", "@matrix-widget-toolkit/api@^5.1.0": + version "5.1.0" + resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/api/-/api-5.1.0.tgz#43e5ff0a3152728b6836409fd5bddc3a121a1326" + integrity sha512-mOGg1nwh3Zw9vSzFtvRQAtsz3dvUrBWLhcBuT4XwKhpI2dmQGb7EniG+1xjqLNW7D2lnk/Vla9EM3sygLncw9g== dependencies: - joi "^17.13.3" - matrix-widget-api "^1.13.1" - qs "^6.14.1" + joi "^17.13.4" + matrix-widget-api "^1.18.0" + qs "^6.15.3" rxjs "^7.8.2" -"@matrix-widget-toolkit/mui@2.2.1", "@matrix-widget-toolkit/mui@^2.2.1": - version "2.2.1" - resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/mui/-/mui-2.2.1.tgz#5bd35809f17824316620badd937e7e9d3f9a3200" - integrity sha512-KNCZzyS61SBNsaOOOMn5/SLPC4tpFCB9x9qcP4gqw+Z/kSgY6R6kfapz2CGQdZtsS2qaAJ555xKytrQ4R4DW5Q== +"@matrix-widget-toolkit/mui@2.2.2", "@matrix-widget-toolkit/mui@^2.2.2": + version "2.2.2" + resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/mui/-/mui-2.2.2.tgz#920a8f43c650f3dcceba4fa4c77b956c16a6f144" + integrity sha512-M/mdHsKO7EZgWSre6mnm5mdb0xyHsgJoiTacHglShS9JG487mEmBCJVWORRV4l9+fq3aSf60TBYFcjSjs1sDnQ== dependencies: "@emotion/cache" "^11.14.0" "@emotion/react" "^11.14.0" "@emotion/styled" "^11.14.1" - "@fontsource/inter" "^5.1.1" - "@matrix-widget-toolkit/api" "^5.0.3" - "@matrix-widget-toolkit/react" "^2.1.1" + "@fontsource/inter" "^5.2.8" + "@matrix-widget-toolkit/api" "^5.1.0" + "@matrix-widget-toolkit/react" "^2.1.2" "@mui/icons-material" "^6.5.0" "@mui/material" "^6.5.0" "@mui/utils" "^6.4.9" - i18next "^25.7.3" - i18next-browser-languagedetector "^8.2.0" - i18next-resources-to-backend "^1.2.1" - matrix-widget-api "^1.13.1" + i18next "^25.10.10" + i18next-browser-languagedetector "^8.2.1" + i18next-resources-to-backend "^1.2.3" + matrix-widget-api "^1.18.0" react-i18next "^15.7.4" - react-use "^17.6.0" + react-use "^17.6.1" -"@matrix-widget-toolkit/react@2.1.1", "@matrix-widget-toolkit/react@^2.1.1": - version "2.1.1" - resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/react/-/react-2.1.1.tgz#19e1332fe4ff9ff9e82f1085e6027819517632a3" - integrity sha512-C2baCjWgx2Hm5sekwicEfQkfQ2fxt6mM3MqD7PUFK9VmEZ5C91Tb5lg9KoS5dXr6+X85J4X4IG41nXTjHlXfnQ== +"@matrix-widget-toolkit/react@2.1.2", "@matrix-widget-toolkit/react@^2.1.2": + version "2.1.2" + resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/react/-/react-2.1.2.tgz#def1570249dc3ef719fb50a0f39ba59a5e6ddfaf" + integrity sha512-2sKPxRBAu0JTjAGH8TVfYHgzQPyOeB1tYvB31dq1LO/YgaJS/rE164x8O9tJQZkLUpxqDIXw7iGBcNadzIuFdw== dependencies: - "@matrix-widget-toolkit/api" "^5.0.3" - matrix-widget-api "^1.13.1" - react-error-boundary "^6.0.0" - react-use "^17.6.0" + "@matrix-widget-toolkit/api" "^5.1.0" + matrix-widget-api "^1.18.0" + react-error-boundary "^6.0.3" + react-use "^17.6.1" -"@matrix-widget-toolkit/testing@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/testing/-/testing-4.0.1.tgz#7aa92b74d00ce086bd8a6f6f553e599e01cb3d40" - integrity sha512-HIH7UglqjdeCKoT0pX+CNXrUAg8U0WtFzsxIonmDVhQZrRMLcdMlSHh6FwN4ki900F2ROsOsVAmxuyh5fJSung== +"@matrix-widget-toolkit/testing@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@matrix-widget-toolkit/testing/-/testing-4.1.0.tgz#32c2af2a4e424baf6d78e04d082656989172aea3" + integrity sha512-VVOsx239Uqeb6TOKEKSJrOLGlriN6ZYa3XCJIl/shIRwoCx56Q30/rZ4nGcHLAyminrbThbkZwDBjURnwU9ySA== dependencies: - "@matrix-widget-toolkit/api" "^5.0.3" - matrix-widget-api "^1.13.1" + "@matrix-widget-toolkit/api" "^5.1.0" + matrix-widget-api "^1.18.0" rxjs "^7.8.2" "@mui/base@5.0.0-beta.70", "@mui/base@^5.0.0-beta.70": @@ -1585,148 +1455,67 @@ resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b" integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g== -"@swc/core-darwin-arm64@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz#e812659bb23c5a078c05c8b18aad25e9d3a12e39" - integrity sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g== - "@swc/core-darwin-arm64@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz#d84134fb80417d41128739f0b9014542e3ed9dd3" integrity sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA== -"@swc/core-darwin-x64@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz#99e38bdb00a6975d54471f77c345b4ee9bbb4502" - integrity sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg== - "@swc/core-darwin-x64@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz#0badb9834071f1c6005986571d4a96359c1d7cd0" integrity sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA== -"@swc/core-linux-arm-gnueabihf@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz#3555ef64268825e4975409b7ed3e9f614e2f9759" - integrity sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw== - "@swc/core-linux-arm-gnueabihf@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz#b7577a825b59d98b6a9a5c991d842046efe1c34a" integrity sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ== -"@swc/core-linux-arm64-gnu@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz#15677103362e56826bb8bc4e15090228f81ef448" - integrity sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA== - "@swc/core-linux-arm64-gnu@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz#304c48321494a18c67b2913c273b08674ee70d8c" integrity sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw== -"@swc/core-linux-arm64-musl@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz#d1655edac4d0101b9c21193770fb8642ced7ef37" - integrity sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg== - "@swc/core-linux-arm64-musl@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz#d116cbc04ccb4f4ee810da6bca79d4423605dbcd" integrity sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og== -"@swc/core-linux-ppc64-gnu@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz#65dc9265686cc24a63d5d8a41a01efe432993181" - integrity sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ== - "@swc/core-linux-ppc64-gnu@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz#f5354dba36db9414305bab344c817d57b8b457c2" integrity sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog== -"@swc/core-linux-s390x-gnu@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz#89c575dbfa39fde1d83033bf4d13e1bf93c93f45" - integrity sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw== - "@swc/core-linux-s390x-gnu@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz#016df9f4c9d7fd65b85ca9c558c5aec341f06da0" integrity sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA== -"@swc/core-linux-x64-gnu@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz#5275c0b24b01b26fdb7a1b5da6bd062d94f9581f" - integrity sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw== - "@swc/core-linux-x64-gnu@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz#49f36558ede072e71999aa37f123367daed2a662" integrity sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw== -"@swc/core-linux-x64-musl@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz#e65e6f0d7215ded63c0711c2284fe13127772209" - integrity sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg== - "@swc/core-linux-x64-musl@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz#b096665f5cfeee2612325f301da5c1590b10d8f3" integrity sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ== -"@swc/core-win32-arm64-msvc@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz#12dff91f148bc4e2e48d7990f175f108199f6f0d" - integrity sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA== - "@swc/core-win32-arm64-msvc@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz#f3101263a0dbaa173ec47638c9719d0b89838bd2" integrity sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g== -"@swc/core-win32-ia32-msvc@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz#eb3747533a3c078bfee5d857b086774647506a4a" - integrity sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ== - "@swc/core-win32-ia32-msvc@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz#eb981ef5613d42c9220559bdb0c8bc58cf6c3eb9" integrity sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ== -"@swc/core-win32-x64-msvc@1.15.24": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz#a0ad3bb9b8755093efe656299aa167138a589708" - integrity sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ== - "@swc/core-win32-x64-msvc@1.15.33": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz#a2fed9956933027ceb368857bac4bb4ee203d47c" integrity sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg== -"@swc/core@^1.13.5", "@swc/core@^1.15.18": - version "1.15.24" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.24.tgz#258dd1f74c662d9a2535fcfa2aa8ff30fd23883d" - integrity sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ== - dependencies: - "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.26" - optionalDependencies: - "@swc/core-darwin-arm64" "1.15.24" - "@swc/core-darwin-x64" "1.15.24" - "@swc/core-linux-arm-gnueabihf" "1.15.24" - "@swc/core-linux-arm64-gnu" "1.15.24" - "@swc/core-linux-arm64-musl" "1.15.24" - "@swc/core-linux-ppc64-gnu" "1.15.24" - "@swc/core-linux-s390x-gnu" "1.15.24" - "@swc/core-linux-x64-gnu" "1.15.24" - "@swc/core-linux-x64-musl" "1.15.24" - "@swc/core-win32-arm64-msvc" "1.15.24" - "@swc/core-win32-ia32-msvc" "1.15.24" - "@swc/core-win32-x64-msvc" "1.15.24" - -"@swc/core@^1.15.26": +"@swc/core@^1.13.5", "@swc/core@^1.15.18", "@swc/core@^1.15.26": version "1.15.33" resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.33.tgz#2a6571c8aca961925f14beae52b3f43c18370fc6" integrity sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ== @@ -1815,16 +1604,11 @@ resolved "https://registry.yarnpkg.com/@types/deep-eql/-/deep-eql-4.0.2.tgz#334311971d3a07121e7eb91b684a605e7eea9cbd" integrity sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw== -"@types/estree@1.0.9", "@types/estree@^1.0.0": +"@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.9" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== -"@types/estree@^1.0.6": - version "1.0.8" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" - integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== - "@types/events@^3.0.0": version "3.0.3" resolved "https://registry.yarnpkg.com/@types/events/-/events-3.0.3.tgz#a8ef894305af28d1fc6d2dfdfc98e899591ea529" @@ -1838,10 +1622,10 @@ "@types/react" "*" hoist-non-react-statics "^3.3.0" -"@types/js-cookie@^2.2.6": - version "2.2.7" - resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3" - integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA== +"@types/js-cookie@^3.0.0": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-3.0.6.tgz#a04ca19e877687bd449f5ad37d33b104b71fdf95" + integrity sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ== "@types/json-schema@^7.0.15": version "7.0.15" @@ -1858,10 +1642,10 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40" integrity sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ== -"@types/node@*": - version "22.19.15" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.19.15.tgz#6091d99fdf7c08cb57dc8b1345d407ba9a1df576" - integrity sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg== +"@types/node@*", "@types/node@^22.20.0": + version "22.20.1" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.20.1.tgz#84e7cdf63cdaa20c134aa317ccc901aa21e16f0e" + integrity sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q== dependencies: undici-types "~6.21.0" @@ -1870,13 +1654,6 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.20.55.tgz#c329cbd434c42164f846b909bd6f85b5537f6240" integrity sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ== -"@types/node@^22.20.0": - version "22.20.1" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.20.1.tgz#84e7cdf63cdaa20c134aa317ccc901aa21e16f0e" - integrity sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q== - dependencies: - undici-types "~6.21.0" - "@types/parse-json@^4.0.0": version "4.0.2" resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.2.tgz#5950e50960793055845e956c427fc2b0d70c5239" @@ -3168,38 +2945,6 @@ es-to-primitive@^1.3.0: is-date-object "^1.0.5" is-symbol "^1.0.4" -esbuild@^0.27.0: - version "0.27.7" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" - integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== - optionalDependencies: - "@esbuild/aix-ppc64" "0.27.7" - "@esbuild/android-arm" "0.27.7" - "@esbuild/android-arm64" "0.27.7" - "@esbuild/android-x64" "0.27.7" - "@esbuild/darwin-arm64" "0.27.7" - "@esbuild/darwin-x64" "0.27.7" - "@esbuild/freebsd-arm64" "0.27.7" - "@esbuild/freebsd-x64" "0.27.7" - "@esbuild/linux-arm" "0.27.7" - "@esbuild/linux-arm64" "0.27.7" - "@esbuild/linux-ia32" "0.27.7" - "@esbuild/linux-loong64" "0.27.7" - "@esbuild/linux-mips64el" "0.27.7" - "@esbuild/linux-ppc64" "0.27.7" - "@esbuild/linux-riscv64" "0.27.7" - "@esbuild/linux-s390x" "0.27.7" - "@esbuild/linux-x64" "0.27.7" - "@esbuild/netbsd-arm64" "0.27.7" - "@esbuild/netbsd-x64" "0.27.7" - "@esbuild/openbsd-arm64" "0.27.7" - "@esbuild/openbsd-x64" "0.27.7" - "@esbuild/openharmony-arm64" "0.27.7" - "@esbuild/sunos-x64" "0.27.7" - "@esbuild/win32-arm64" "0.27.7" - "@esbuild/win32-ia32" "0.27.7" - "@esbuild/win32-x64" "0.27.7" - "esbuild@^0.27.0 || ^0.28.0": version "0.28.1" resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" @@ -3950,7 +3695,7 @@ hyphenate-style-name@^1.0.3: resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz#1797bf50369588b47b72ca6d5e65374607cf4436" integrity sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw== -i18next-browser-languagedetector@^8.2.0, i18next-browser-languagedetector@^8.2.1: +i18next-browser-languagedetector@^8.2.1: version "8.2.1" resolved "https://registry.yarnpkg.com/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz#f17a918d376a97aa12a5b63fd8ea559a6231935b" integrity sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw== @@ -4003,14 +3748,14 @@ i18next-resources-for-ts@^2.1.0: chokidar "^5.0.0" yaml "^2.8.2" -i18next-resources-to-backend@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.1.tgz#fded121e63e3139ce839c9901b9449dbbea7351d" - integrity sha512-okHbVA+HZ7n1/76MsfhPqDou0fptl2dAlhRDu2ideXloRRduzHsqDOznJBef+R3DFZnbvWoBW+KxJ7fnFjd6Yw== +i18next-resources-to-backend@^1.2.1, i18next-resources-to-backend@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/i18next-resources-to-backend/-/i18next-resources-to-backend-1.2.3.tgz#f2f58dab29f5514a6010b4e13c76f0714d62134e" + integrity sha512-8Y/LLAm5fqZc2ckQxtTWbu75ndjNLzF7mcYl6rhc4g+WBopIsG3YhdYqF5qhJy64tlYhlU2KSSnEgW3gGHLqwg== dependencies: "@babel/runtime" "^7.23.2" -i18next@^25.10.10, i18next@^25.7.3: +i18next@^25.10.10: version "25.10.10" resolved "https://registry.yarnpkg.com/i18next/-/i18next-25.10.10.tgz#d610511c87150e7a98c58fa780e93f90603fe187" integrity sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ== @@ -4427,7 +4172,7 @@ jiti@^2.6.1: resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.6.1.tgz#178ef2fc9a1a594248c20627cd820187a4d78d92" integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== -joi@^17.13.3, joi@^17.13.4: +joi@^17.13.4: version "17.13.4" resolved "https://registry.yarnpkg.com/joi/-/joi-17.13.4.tgz#ad6153d97ce558eb3a3b593e0d43eab51df1c474" integrity sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ== @@ -4453,10 +4198,10 @@ js-base64@^3.7.8: resolved "https://registry.yarnpkg.com/js-base64/-/js-base64-3.7.8.tgz#af44496bc09fa178ed9c4adf67eb2b46f5c6d2a4" integrity sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow== -js-cookie@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8" - integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ== +js-cookie@^3.0.0: + version "3.0.8" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.8.tgz#444e6f4b27a5d844594fef61c9d6bca5f0787688" + integrity sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw== "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" @@ -4628,20 +4373,20 @@ listr2@^8.2.5: rfdc "^1.4.1" wrap-ansi "^9.0.0" -livekit-client@^2.18.10: - version "2.19.2" - resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.19.2.tgz#641fca83b7edf3f4022134a7079553794e04017f" - integrity sha512-Kvk07QYDWRAbmYNLRll04ZIuxMQobW/oLPYnmR1kCy8GGHpU0gqyHf704Rz+29zfy8IJZRjKqeVbzGSKn9sumw== +livekit-client@^2.21.0: + version "2.22.0" + resolved "https://registry.yarnpkg.com/livekit-client/-/livekit-client-2.22.0.tgz#1557dd5f7c0654efbb81cbac0f4a81aa008aeb86" + integrity sha512-GLtYQfRh/RsvXaOX1x609bFZ17yyKmKWDqu9JmkMKn9vIFLi2GsapRv9gT8OJO/1R2dsitrkbGmloyUfxWQsaA== dependencies: "@livekit/mutex" "1.1.1" - "@livekit/protocol" "1.45.8" + "@livekit/protocol" "1.50.4" events "^3.3.0" jose "^6.1.0" loglevel "^1.9.2" sdp-transform "^2.15.0" tslib "2.8.1" typed-emitter "^2.1.0" - webrtc-adapter "9.0.5" + webrtc-adapter "9.0.6" localforage@^1.10.0: version "1.10.0" @@ -4763,10 +4508,18 @@ math-intrinsics@^1.1.0: resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== -matrix-widget-api@^1.13.1, matrix-widget-api@^1.16.1: - version "1.16.1" - resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.16.1.tgz#a447f28f0af07e1bdc960881971de7d1ec9e6464" - integrity sha512-oCfTV4xNPo02qIgveqdkIyKQjOPpsjhF3bmJBotHrhr8TsrhVa7kx8PtuiUPnQTjz0tdBle7falR2Fw8VKsedw== +matrix-widget-api@1.18.0: + version "1.18.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.18.0.tgz#3366d7ad14df62df1465a9384bc9557e72bbab79" + integrity sha512-4T2f2koWmx05p1BLcT/9YGGGPSXpPT+PA4Oap/5fjhXsWPxMGJiGL97YpANMYLKsnE1sScYFeuyxIgdc1Qo+Ew== + dependencies: + "@types/events" "^3.0.0" + events "^3.2.0" + +matrix-widget-api@^1.18.0: + version "1.19.0" + resolved "https://registry.yarnpkg.com/matrix-widget-api/-/matrix-widget-api-1.19.0.tgz#77819e0307adbba972ae8178c83b77fada896e29" + integrity sha512-Qe7L6G1mQJphBC47/Bzp5ljPIK1UARgLiuGgS+/KmEs/eaNHkQ3+ArD0/0kvMV+d6kCGAZ9OKRqsnuPjizI+tw== dependencies: "@types/events" "^3.0.0" events "^3.2.0" @@ -4897,7 +4650,7 @@ nano-css@^5.6.2: stacktrace-js "^2.0.2" stylis "^4.3.0" -nanoid@^3.3.11, nanoid@^3.3.12: +nanoid@^3.3.12: version "3.3.12" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05" integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== @@ -4939,10 +4692,10 @@ object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-inspect@^1.13.3: - version "1.13.3" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" - integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== object-is@^1.1.5: version "1.1.6" @@ -5261,16 +5014,7 @@ possible-typed-array-names@^1.0.0: resolved "https://registry.yarnpkg.com/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz#89bb63c6fada2c3e90adc4a647beeeb39cc7bf8f" integrity sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q== -postcss@^8.4.44: - version "8.5.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" - integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== - dependencies: - nanoid "^3.3.11" - picocolors "^1.1.1" - source-map-js "^1.2.1" - -postcss@^8.5.6: +postcss@^8.4.44, postcss@^8.5.6: version "8.5.15" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== @@ -5329,12 +5073,13 @@ punycode@^2.1.0, punycode@^2.3.1: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -qs@^6.14.1: - version "6.15.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.1.tgz#bdb55aed06bfac257a90c44a446a73fba5575c8f" - integrity sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg== +qs@^6.15.3: + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== dependencies: - side-channel "^1.1.0" + es-define-property "^1.0.1" + side-channel "^1.1.1" queue-microtask@^1.2.2: version "1.2.3" @@ -5384,10 +5129,10 @@ react-dropzone@^14.3.8: file-selector "^2.1.0" prop-types "^15.8.1" -react-error-boundary@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.0.2.tgz#c4f5a18510cbe54874c23d759f19c99471816c9e" - integrity sha512-yvWErn55ag/ywZEFqYpXYX9rxIDPIabXIX25F184KY3F5Szk2x/cVieOflw5R47ltN3KzWOw82Lmlb4vNjyn9A== +react-error-boundary@^6.0.3: + version "6.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-6.1.4.tgz#cd690f51bde608b761130f2214e94d8b3ee4c3ab" + integrity sha512-7FgvbyjCFhu4dtKSZu807cW1MWG2nWAM4bwGqUByAtHVJXtlzoCJX0SSyZRyFrb+aBd7wtJkcOR4DsxXVEuDsA== react-floater@^0.7.9: version "0.7.9" @@ -5405,10 +5150,10 @@ react-hotkeys-hook@^5.3.2: resolved "https://registry.yarnpkg.com/react-hotkeys-hook/-/react-hotkeys-hook-5.3.2.tgz#7715fcc43dc4be6efefe04eca22f8faa0f799567" integrity sha512-DDDy9xK6mbTQ6aPlQvIl0dA/a90T/AWml4Rm21JXFDLlRHalIg4/Rv3equUQYs5xPTWq+oEl6RD7mi/nBpU3Uw== -react-i18next@*: - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-17.0.2.tgz#78968f74d9a51b5c4ccee14431f26496d8c122f8" - integrity sha512-shBftH2vaTWK2Bsp7FiL+cevx3xFJlvFxmsDFQSrJc+6twHkP0tv/bGa01VVWzpreUVVwU+3Hev5iFqRg65RwA== +react-i18next@*, react-i18next@^17.0.7: + version "17.0.7" + resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-17.0.7.tgz#f66e0f9a336e31b641d89bcb654d4aee21b5409c" + integrity sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg== dependencies: "@babel/runtime" "^7.29.2" html-parse-stringify "^3.0.1" @@ -5422,15 +5167,6 @@ react-i18next@^15.5.2, react-i18next@^15.7.4: "@babel/runtime" "^7.27.6" html-parse-stringify "^3.0.1" -react-i18next@^17.0.7: - version "17.0.7" - resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-17.0.7.tgz#f66e0f9a336e31b641d89bcb654d4aee21b5409c" - integrity sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg== - dependencies: - "@babel/runtime" "^7.29.2" - html-parse-stringify "^3.0.1" - use-sync-external-store "^1.6.0" - react-innertext@^1.1.5: version "1.1.5" resolved "https://registry.yarnpkg.com/react-innertext/-/react-innertext-1.1.5.tgz#8147ac54db3f7067d95f49e2d2c05a720d27d8d0" @@ -5503,17 +5239,17 @@ react-universal-interface@^0.6.2: resolved "https://registry.yarnpkg.com/react-universal-interface/-/react-universal-interface-0.6.2.tgz#5e8d438a01729a4dbbcbeeceb0b86be146fe2b3b" integrity sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw== -react-use@^17.6.0: - version "17.6.0" - resolved "https://registry.yarnpkg.com/react-use/-/react-use-17.6.0.tgz#2101a3a79dc965a25866b21f5d6de4b128488a14" - integrity sha512-OmedEScUMKFfzn1Ir8dBxiLLSOzhKe/dPZwVxcujweSj45aNM7BEGPb9BEVIgVEqEXx6f3/TsXzwIktNgUR02g== +react-use@^17.6.0, react-use@^17.6.1: + version "17.6.1" + resolved "https://registry.yarnpkg.com/react-use/-/react-use-17.6.1.tgz#c9692540f2230b763e42076fbf350e7e33ec2e21" + integrity sha512-uibb3pgzV4LFsYPHyXYGu7dD2+pyk/ZJlPH+AizBR3zolqPWyCleKcWWbUQaKSKfydwgnQ3ymGm1Ab3/saGHCA== dependencies: - "@types/js-cookie" "^2.2.6" + "@types/js-cookie" "^3.0.0" "@xobotyi/scrollbar-width" "^1.9.5" copy-to-clipboard "^3.3.1" fast-deep-equal "^3.1.3" fast-shallow-equal "^1.0.0" - js-cookie "^2.2.1" + js-cookie "^3.0.0" nano-css "^5.6.2" react-universal-interface "^0.6.2" resize-observer-polyfill "^1.5.1" @@ -5887,13 +5623,13 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== -side-channel-list@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" - integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== +side-channel-list@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.1.tgz#c2e0b5a14a540aebee3bbc6c3f8666cc9b509127" + integrity sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w== dependencies: es-errors "^1.3.0" - object-inspect "^1.13.3" + object-inspect "^1.13.4" side-channel-map@^1.0.1: version "1.0.1" @@ -5916,14 +5652,14 @@ side-channel-weakmap@^1.0.2: object-inspect "^1.13.3" side-channel-map "^1.0.1" -side-channel@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" - integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== +side-channel@^1.1.0, side-channel@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" + integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== dependencies: es-errors "^1.3.0" - object-inspect "^1.13.3" - side-channel-list "^1.0.0" + object-inspect "^1.13.4" + side-channel-list "^1.0.1" side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" @@ -6521,21 +6257,7 @@ vite-node@3.2.4: pathe "^2.0.3" vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": - version "7.3.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.5.tgz#90c2d0b7b94a224e7e7dcf22d2912ff0b5291165" - integrity sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww== - dependencies: - esbuild "^0.27.0" - fdir "^6.5.0" - picomatch "^4.0.3" - postcss "^8.5.6" - rollup "^4.43.0" - tinyglobby "^0.2.15" - optionalDependencies: - fsevents "~2.3.3" - -vite@^7.3.5: +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^7.3.5: version "7.3.6" resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" integrity sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg== @@ -6605,10 +6327,10 @@ webidl-conversions@^7.0.0: resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz#256b4e1882be7debbf01d05f0aa2039778ea080a" integrity sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g== -webrtc-adapter@9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-9.0.5.tgz#fb036c3db06e1a0d86c264da649e130187783b3f" - integrity sha512-U9vjByy/sK2OMXu5mmfuZFKTMIUQe34c0JXRO+oDrxJTsntdYT2iIFwYMOV7HhMTuktcZLGf2W1N/OcSf9ssWg== +webrtc-adapter@9.0.6: + version "9.0.6" + resolved "https://registry.yarnpkg.com/webrtc-adapter/-/webrtc-adapter-9.0.6.tgz#d89ce0d7b6b8b49f07bdbd1273074006744905a6" + integrity sha512-CHbl2ZQbxx164IgWRgzJno4hWtM4tFbRam1QfI3Yxhs3w/DvqluVxVWeXs3oL5/fbGkSNLKo0Ty5MgUWceNhog== dependencies: sdp "^3.2.0"