Compare commits

..
Author SHA1 Message Date
Valere ebb7fe07b7 add more test 2026-09-03 11:47:35 +02:00
Valere 873dfdbfbd Drop the unused root element provider
RootElementProvider was exported but never used: nothing supplies a root
element, so every consumer falls back to the document body. Knip reports
it, which is what is failing CI.

M1 adds a provider back along with the component that mounts Element Call
into a container. Until then there is nothing to provide.
2026-09-02 19:46:44 +02:00
Valere e68d1d1b5f Say what the root element seam cannot do yet
RootElementContext's documentation promised that Element Call confines its
decoration to the given element, but several selectors still name body
directly and the initializer writes data-platform onto it. A non-body root
is decorated correctly and styled incorrectly, with no error to show for
it, so the documentation should say so until the stylesheets are scoped.
2026-09-02 19:28:14 +02:00
Valere b7efa02adc review: Follow a swapped client 2026-09-02 19:27:44 +02:00
Valere c807b8ef7d Always stop the widget transport when closing
close() sent io.element.close and then stopped the transport, so a
rejected send skipped the stop. Both call sites this replaced stopped it
unconditionally — ErrorView in a finally, GroupCallView outside its
try/catch — because a host that never acknowledges the request would
otherwise leave the messaging live and the close button doing nothing.

Restore that with a finally.
2026-09-02 19:26:59 +02:00
Valere 6b5f396e1e Share the mapping from parameters to call view model options
The SDK stopped sending join notifications: it threaded callIntent into
createCallViewModel$ but not its pair sendNotificationType, which
enterRTCSession used to read for itself, so an explicit
?sendNotificationType=ring — or an intent that implies one — no longer
reached joinRTCSession.

The mechanism is worth fixing rather than the instance. The defaults on
CallViewModelOptions describe a standalone Element Call, so a widget
caller that misses a field gets standalone behaviour rather than an error,
and the SDK is only ever a widget. Give both callers one shared mapping so
they cannot drift, and cover the whole chain from URL to options in tests.

autoLeaveWhenOthersLeft and waitForCallPickup stay out of it: the view
model never read those from the parameters, so enabling them for the SDK
would be a change in its behaviour rather than a fix.
2026-09-02 19:26:33 +02:00
Valere 182be8676d Return the widget from initializeWidget instead of assigning a global
Element Call reached the widget API through a mutable module-level
binding, which every consumer imported directly. Nothing outside the app
shell needs it any more, so hand it back from initializeWidget and thread
it through: the initializer returns it, main passes it to App, and App
uses it to build the host bridge and to await the client the host is
lending us.

ClientContext's loadClient is now only about restoring or creating a
session of Element Call's own, since a widget's client arrives as a prop
like any other host's would.

Also fixes an early return added in the previous commit, which skipped
starting the analytics settings listener when a client was supplied. That
was harmless until now, but would have stopped analytics following the
user's choices in widget mode.

sdk/main.ts asked the host to close by hand; it now uses the bridge, which
also stops the transport as the app does.
2026-09-02 16:56:09 +02:00
Valere f6f47ede62 Let a host supply Element Call's client
ClientProvider found its own client: from the widget API, or by restoring
or creating a session. A host that embeds Element Call already has one,
and owns the user's session, so accept it as a prop and skip all of that.

A supplied client seeds the state synchronously, since there is no session
of ours to restore and so nothing to wait for.

Also guard the broadcast that shuts down other instances of the app. It
protects Element Call's own session and crypto stores, which is why it was
already skipped in widget mode — a host's client has the same property, so
without this an embedded Element Call would close down the user's other
tabs on mount.

Adds the first tests for ClientContext, covering both
2026-09-02 15:14:10 +02:00
Valere eb117249d1 Configure analytics explicitly instead of discovering it
PosthogAnalytics read its own configuration out of the environment on
first use: the URL parameters, config.json, and the widget global. An
embedded Element Call has none of those to offer, and analytics that
configure themselves cannot be switched off by a host that does its own
reporting.

Take an AnalyticsConfig through PosthogAnalytics.configure() instead,
called from the initializer once the config has loaded. Unconfigured
analytics stay off.

Note the two halves of that config are decided differently, and have to
be: where the credentials come from depends on the package, but who owns
the user's analytics identity depends on how Element Call is running,
since the full package can be used as a widget too.

Drop the widget check around cryptoVersion, which never did anything —
widget mode never initialises crypto, so getCrypto() is already undefined
there.

Move the tests covering which package reads which credential source onto
analyticsConfigFromEnvironment, where that decision now lives.
2026-09-02 15:05:12 +02:00
Valere f28d9e9b06 Stop asking whether Element Call is a widget
The remaining reads of the widget global were all asking one of two
different questions, so they get two different answers.

The app shell — the auth hooks, automatic guest registration, the group
call loader's diagnostic and the initial mute state — wants to know
whether Element Call was launched as a widget. That is a property of the
URL it was launched with, so expose the isWidget that computeUrlParams
already computed internally, documented as being for shell use only.

The call interface — whether to offer the profile settings tab — wants to
know something about its host, so it asks the bridge. A host that can
dismiss Element Call owns the user's account, so their profile is not ours
to edit; this reuses the close capability as a proxy, with a TODO
alongside the others.

ClientContext also takes supportsReactions from the bridge rather than
checking four widget capabilities itself, which removes widgetApi from
InitResult — a field that was always null outside widget mode.

Note this changes behaviour for a malformed widget URL: one carrying a
widget ID and parent URL but missing the room, user, device or base URL
would previously have fallen back to registering a guest user, and will
now not.
2026-09-02 15:04:24 +02:00
Valere bc6c232ef0 Move the state layer onto the host bridge
MuteStates, CallViewModel and LocalMember reached the host through the
widget global. None of them are React components, so they take the bridge
as an explicit parameter: a constructor argument for MuteStates, a field
on CallViewModelOptions, and one on createLocalMembership$'s props.

src/state no longer refers to the widget API.

The conditionals around it mostly disappear: nullHostBridge's observables
are NEVER, so there is nothing to guard, and a request carries its own
reply rather than needing the transport and the original event.

CallViewModelWidget.test.ts drove hangup by emitting on the mocked
widget's action emitter, so it now injects a bridge instead, and checks
that the request is acknowledged.
2026-09-02 13:23:01 +02:00
Valere 8e78ac76bd Drop ErrorView's widget prop in favour of the host bridge
ErrorView needed a widget only to decide whether to offer a close button
or a link home. That prop was threaded down through ErrorPage, RichError
and GroupCallErrorBoundary from seven call sites, to answer the single
question of whether the host can dismiss Element Call — which the host
bridge now answers directly through the presence of close().

Read the bridge from context in ErrorView and remove the prop, along with
the plumbing that carried it.

No functional change: the rendered output is unchanged, as the existing
snapshot confirms.

Move GroupCallView onto the host bridge

GroupCallView asked the widget API to keep it on screen, to close it, and
to tell it when a preloaded call should join. Route all three through the
host bridge and drop the widget prop.
2026-09-02 13:03:23 +02:00
Valere d8d70812b8 Read the shared room key from the parameters context
useRoomEncryptionSystem is used from GroupCallView, inside the part of
Element Call that will become the embeddable component, but it reached for
getUrlParams() via getKeyForRoom(). Extract the lookup into a helper taking
the room ID and password explicitly: the hook supplies them from the
parameters context, while getKeyForRoom keeps reading the URL for its one
remaining caller in the app shell.

No functional change.
2026-09-02 11:00:42 +02:00
Valere 6e44332fd1 Stop reading URL parameters from inside the call path
The view models reached for getUrlParams() — and so window.location —
from deep inside the call path: CallViewModel, MediaDevices, Publisher,
LocalMember and the footer view model. An embedded Element Call has no URL
of its own, so these values have to arrive as arguments instead.

Add the relevant options to CallViewModelOptions, to the MediaDevices and
Publisher constructors, to createLocalMembership$ and enterRTCSession, and
to createCallFooterViewModel. The remaining React consumers read the
context added in the previous commit. AppViewModel now takes its audio
output options too, moving that URL read out to main.tsx, where the app
shell can act as the adapter.

The new CallViewModelOptions fields are optional, defaulting to what the
URL parameters resolve to outside widget mode; the MediaDevices and
Publisher arguments are required, so that every construction site has to
be explicit.

useTheme.test.ts mocked the UrlParams module with a factory, so it needed
updating to mock the hook rather than getUrlParams.

No functional change.
2026-09-02 10:31:48 +02:00
Valere bc6015eff2 Supply Element Call's parameters through a context
Element Call's parameters come from its URL, which works while it owns
the page but leaves an embedder with nowhere to put them. Add a context
so they can be provided directly, falling back to parsing
window.location when no provider is present.

This also decouples the thirteen consumers from react-router: useUrlParams
called useLocation, so each of them required a router ancestor, which the
embedded build will not have. The standalone and widget builds keep their
URL-derived behaviour via useUrlParamsFromLocation, provided in App.

No functional change.
2026-09-02 09:58:34 +02:00
Valere 0dca5835f6 Route Element Call's DOM decoration through an injectable root element
Element Call writes its theme classes, background and layout attributes
straight onto document.body, and portals its modals there too. That is
only correct while it owns the page; embedded in a host application it
has to confine itself to the container it was mounted into.

Add a RootElementContext, defaulting to document.body so that the
standalone and widget builds are unaffected, and point the theme classes,
data-background, no-scroll-body and the fullscreen target at it.

Give the Modal and Toast portals an explicit container as well. Radix and
vaul both default to document.body, so without this every modal, drawer
and toast would render outside the container and lose the theme and
platform attributes set on it.

No functional change: the root element is document.body until an embedder
provides otherwise.
2026-09-01 18:54:27 +02:00
Valere 0a8c24bca8 Allow the config to be supplied by an embedder
Config.init() derives the location of config.json from window.location,
which only makes sense while Element Call owns the page. When it is
embedded in a host application the host owns the configuration, so add
Config.initWith() to accept it directly.

Share the defaulting and validation between both paths via resolveConfig(),
so that an injected config behaves identically to a fetched one, and mark
initialization as complete so that the init() calls already on the startup
path resolve immediately instead of fetching over the top of it.

initDefault() becomes initWith({}), which also stops it handing out a
shallow copy of DEFAULT_CONFIG whose nested objects were shared with the
module-level default.
2026-09-01 18:40:53 +02:00
Valere 7485b3d71f Use a dedicated i18next instance instead of the global singleton
Element Call configured the global i18next singleton. When Element Call
runs embedded in a host application rather than as its own page, that
singleton belongs to the host, so configuring it would clobber the
host's translations.

Create Element Call's own instance in utils/i18n.ts, configure it in the
initializer, and pass it to components via <I18nextProvider>. Drop
.use(initReactI18next) from the initializer: it registers the instance
as react-i18next's global default, which is the global we are trying to
avoid. Tests and stories keep using it, so that they do not need to wrap
every render in a provider.

Two modules imported `t` directly from "i18next" and so were bound to
the global instance: utils/errors.ts now calls i18n.t() on the instance
(reached at call time, since i18next only assigns `t` during init), and
QrCode uses useTranslation() like every other component.

No functional change.
2026-09-01 18:30:05 +02:00
83 changed files with 2160 additions and 1464 deletions
+2 -2
View File
@@ -5,7 +5,7 @@
> Every PR must have a linked issue
> that a maintainer has reviewed and approved **before you started writing code**.
> PRs that don't meet this requirement will not be reviewed.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) for ElementCall decided for this approach.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) for ElementCall decided for this approach.
## Content
@@ -39,7 +39,7 @@ Uncomment the markdown table below and fill in the last line:
## Checklist
- [ ] A linked, pre-approved issue exists for this feature or UI change.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) in full.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md) in full.
- [ ] Pull request includes screenshots or videos for any UI changes.
- [ ] Tests written for new code (and existing touched code where feasible).
- [ ] Linter and other CI checks pass.
+5 -5
View File
@@ -6,7 +6,7 @@ on:
- opened
- labeled
push:
branches: [main]
branches: [livekit, full-mesh]
jobs:
build_full_element_call:
# Use the full package vite build
@@ -22,8 +22,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
deploy_develop:
# Deploy main branch to call.element.dev after build completes
if: github.ref == 'refs/heads/main'
# Deploy livekit branch to call.element.dev after build completes
if: github.ref == 'refs/heads/livekit'
needs: build_full_element_call
runs-on: ubuntu-latest
steps:
@@ -43,8 +43,8 @@ jobs:
}
})
docker_for_develop:
# Build docker and publish docker for main branch after build completes
if: github.ref == 'refs/heads/main'
# Build docker and publish docker for livekit branch after build completes
if: github.ref == 'refs/heads/livekit'
needs: build_full_element_call
permissions:
contents: write
+4 -2
View File
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
import type { Preview } from "@storybook/react-vite";
import { TooltipProvider } from "@vector-im/compound-web";
import i18n from "i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import EN from "../locales/en/app.json";
import { initReactI18next } from "react-i18next";
import { i18n } from "../src/utils/i18n";
import "../src/index.css";
// Bare-minimum i18n config
// Bare-minimum i18n config.
// Unlike the app, stories register the instance as react-i18next's default
// rather than wrapping every story in an <I18nextProvider>.
i18n
.use(initReactI18next)
.init({
+2 -2
View File
@@ -13,7 +13,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
image: ghcr.io/element-hq/element-web:develop@sha256:c107ec33fa349cb5c14fd03a4e13352d64ef264416dac96d273aa07b4004171f
-1
View File
@@ -116,7 +116,6 @@
"peer_connection_timeout_description": "Connection to the media server timed out. Try switching to a different network or disabling your VPN. If the problem persists, see our <0>troubleshooting guide</0> or contact your server administrator.",
"room_creation_restricted": "Failed to create call",
"room_creation_restricted_description": "Call creation might be restricted to authorized users only. Try again later, or contact your server admin if the problem persists.",
"screen_share_failed": "Could not start screen sharing",
"sticky_events_required": "Homeserver does not support Matrix 2.0 calls",
"sticky_events_required_description": "This deployment is configured to use Matrix 2.0 call mode, but the homeserver does not advertise support for sticky events (MSC4354). Ask your server admin to upgrade, or switch the deployment to a compatible mode.",
"unexpected_ec_error": "An unexpected error occurred (<0>Error Code:</0> <1>{{ errorCode }}</1>). Please contact your server admin."
+1 -1
View File
@@ -153,7 +153,7 @@ export const widgetTest = test.extend<MyFixtures>({
// Accept the DM invite from brooks
// This how playwright record selects the DM invite in the room list
await ewPage2.getByRole("button", { name: "Open room" }).click();
await ewPage2.getByRole("option", { name: "Open room" }).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
+18 -25
View File
@@ -39,13 +39,13 @@ importers:
version: 11.7.12
'@livekit/components-core':
specifier: ^0.12.0
version: 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
version: 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-react':
specifier: ^2.0.0
version: 2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
version: 2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)
'@livekit/track-processors':
specifier: ^0.7.1
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))
version: 0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))
'@mediapipe/tasks-vision':
specifier: ^0.10.18
version: 0.10.35
@@ -183,7 +183,7 @@ importers:
version: 5.88.1(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)(@types/node@24.13.3)(typescript@5.9.3)
livekit-client:
specifier: ^2.18.1
version: 2.22.2(@types/dom-mediacapture-record@1.0.22)
version: 2.22.0(@types/dom-mediacapture-record@1.0.22)
lodash-es:
specifier: ^4.17.21
version: 4.18.1
@@ -4465,8 +4465,8 @@ packages:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
jose@6.2.12:
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
jose@6.2.10:
resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==}
jose@6.2.9:
resolution: {integrity: sha512-XrchZOFZUl/T3vTwRe8XK+cJrGtMF4th1ARnDfwbBXFKThGhlsxEE4Zu03AD/bjJSt/9jT/mxrOCkJWOg77aPA==}
@@ -4614,8 +4614,8 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
livekit-client@2.22.2:
resolution: {integrity: sha512-o3yLYoH2eHVR/DmIZzgC96FMvtR4+g/DjeqNwS5zp12EjqrqiRULxm6Oq/YsTE97WnA6fhy23+a2hGkbce5SPA==}
livekit-client@2.22.0:
resolution: {integrity: sha512-GLtYQfRh/RsvXaOX1x609bFZ17yyKmKWDqu9JmkMKn9vIFLi2GsapRv9gT8OJO/1R2dsitrkbGmloyUfxWQsaA==}
peerDependencies:
'@types/dom-mediacapture-record': ^1
@@ -4664,10 +4664,6 @@ packages:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
machina@7.0.1:
resolution: {integrity: sha512-0Xkgs8NeBvO2hW6bjJT8ewLWrMfh1haMPstaghpASWjXkLjRhtDiaJG1Kb7mslaOHxZYxpH7PIjpmirRVYbMMA==}
engines: {node: '>=22.22'}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -7074,21 +7070,21 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@livekit/components-core@0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
'@livekit/components-core@0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)':
dependencies:
'@floating-ui/dom': 1.7.6
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
loglevel: 1.9.1
rxjs: 7.8.2
tslib: 2.8.1
'@livekit/components-react@2.9.24(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
'@livekit/components-react@2.9.24(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(react-dom@19.2.8(react@19.2.8))(react@19.2.8)(tslib@2.8.1)':
dependencies:
'@livekit/components-core': 0.12.15(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
'@livekit/components-core': 0.12.15(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))(tslib@2.8.1)
clsx: 2.1.1
events: 3.3.0
jose: 6.2.9
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
react: 19.2.8
react-dom: 19.2.8(react@19.2.8)
tslib: 2.8.1
@@ -7100,11 +7096,11 @@ snapshots:
dependencies:
'@bufbuild/protobuf': 1.10.1
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22))':
'@livekit/track-processors@0.7.2(@types/dom-mediacapture-transform@0.1.11)(livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22))':
dependencies:
'@mediapipe/tasks-vision': 0.10.35
'@types/dom-mediacapture-transform': 0.1.11
livekit-client: 2.22.2(@types/dom-mediacapture-record@1.0.22)
livekit-client: 2.22.0(@types/dom-mediacapture-record@1.0.22)
'@matrix-org/matrix-sdk-crypto-wasm@18.5.0': {}
@@ -10063,7 +10059,7 @@ snapshots:
jiti@2.7.0: {}
jose@6.2.12: {}
jose@6.2.10: {}
jose@6.2.9: {}
@@ -10207,15 +10203,14 @@ snapshots:
lines-and-columns@1.2.4: {}
livekit-client@2.22.2(@types/dom-mediacapture-record@1.0.22):
livekit-client@2.22.0(@types/dom-mediacapture-record@1.0.22):
dependencies:
'@livekit/mutex': 1.1.1
'@livekit/protocol': 1.50.4
'@types/dom-mediacapture-record': 1.0.22
events: 3.3.0
jose: 6.2.12
jose: 6.2.10
loglevel: 1.9.2
machina: 7.0.1
sdp-transform: 2.15.0
tslib: 2.8.1
typed-emitter: 2.1.0
@@ -10258,8 +10253,6 @@ snapshots:
lz-string@1.5.0: {}
machina@7.0.1: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
+32 -23
View File
@@ -46,7 +46,10 @@ import {
// Can this be done in the tsconfig.json
import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/room/types";
import { type Behavior, constant } from "../src/state/Behavior";
import { createCallViewModel$ } from "../src/state/CallViewModel/CallViewModel";
import {
callViewModelOptionsFromParams,
createCallViewModel$,
} from "../src/state/CallViewModel/CallViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { getUrlParams } from "../src/UrlParams";
import { MuteStates } from "../src/state/MuteStates";
@@ -54,12 +57,9 @@ import { MediaDevices } from "../src/state/MediaDevices";
import { E2eeType } from "../src/e2ee/e2eeType";
import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import {
ElementWidgetActions,
widget as _widget,
initializeWidget,
} from "../src/widget";
import { initializeWidget } from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
import { createWidgetHostBridge } from "../src/HostBridge";
interface MatrixRTCSdk {
/**
@@ -109,14 +109,15 @@ export async function createMatrixRTCSdk(
const scope = new ObservableScope();
// widget client
initializeWidget(application, true);
const widget = _widget;
const widget = initializeWidget(application, true);
if (!widget) throw Error("No widget. This webapp can only start as a widget");
const client = await widget.client;
const hostBridge = createWidgetHostBridge(widget);
logger.info("client created");
// url params
const { roomId } = getUrlParams();
const urlParams = getUrlParams();
const { roomId, controlledAudioDevices, callIntent } = urlParams;
if (roomId === null) throw Error("could not get roomId from url params");
const room = client.getRoom(roomId);
if (room === null) throw Error("could not get room from client");
@@ -128,11 +129,16 @@ export async function createMatrixRTCSdk(
const rtcSession = rtcSessionManager.getRoomSession(room);
// media devices
const mediaDevices = new MediaDevices(scope);
const muteStates = new MuteStates(scope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
const mediaDevices = new MediaDevices(scope, {
controlledAudioDevices,
callIntent,
});
const muteStates = new MuteStates(
scope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
hostBridge,
);
// call view model
const callViewModel = createCallViewModel$(
@@ -141,7 +147,11 @@ export async function createMatrixRTCSdk(
room,
mediaDevices,
muteStates,
{ encryptionSystem: { kind: E2eeType.PER_PARTICIPANT } },
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
hostBridge,
},
of({}),
of({}),
constant({ supported: false, processor: undefined }),
@@ -282,18 +292,17 @@ export async function createMatrixRTCSdk(
});
await leaveResolver.promise;
logger.info("send Unstick");
await widget.api
await hostBridge
.setAlwaysOnScreen(false)
.catch((e) =>
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
),
.catch((e: unknown) =>
logger.error("Failed to set `alwaysOnScreen` to false", e),
);
logger.info("send Close");
await widget.api.transport
.send(ElementWidgetActions.Close, {})
.catch((e) => logger.error("Failed to send close action", e));
await hostBridge
.close?.()
.catch((e: unknown) =>
logger.error("Failed to ask the host to close", e),
);
};
// schedule close first and then leave (scope.end)
+96 -53
View File
@@ -5,18 +5,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
type FC,
type JSX,
Suspense,
useEffect,
useMemo,
useState,
} from "react";
import { type FC, type JSX, Suspense, useEffect, useState } from "react";
import { BrowserRouter, Route, useLocation, Routes } from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk";
import { I18nextProvider } from "react-i18next";
import { HomePage } from "./home/HomePage";
import { LoginPage } from "./auth/LoginPage";
@@ -25,13 +20,26 @@ import { RoomPage } from "./room/RoomPage";
import { ClientProvider } from "./ClientContext";
import { ErrorPage, LoadingPage } from "./FullScreenView";
import { Initializer } from "./initializer";
import { widget } from "./widget";
import { type WidgetHelpers } from "./widget";
import { useTheme } from "./useTheme";
import { ProcessorProvider } from "./livekit/TrackProcessorContext";
import { type AppViewModel } from "./state/AppViewModel";
import { MediaDevicesContext } from "./MediaDevicesContext";
import { getUrlParams, HeaderStyle, useUrlParams } from "./UrlParams";
import {
HeaderStyle,
UrlParamsProvider,
useUrlParams,
useUrlParamsFromLocation,
} from "./UrlParams";
import { AppBar } from "./AppBar";
import { i18n } from "./utils/i18n";
import { useRootElement } from "./RootElementContext";
import {
createWidgetHostBridge,
HostBridgeProvider,
nullHostBridge,
} from "./HostBridge";
import { useInitial } from "./useInitial";
const SentryRoute = Sentry.withSentryReactRouterV7Routing(Route);
@@ -39,15 +47,24 @@ interface SimpleProviderProps {
children: JSX.Element;
}
/**
* Supplies the URL-derived params to the rest of the app. Only the standalone
* and widget builds own the URL, so this lives here in the app shell rather
* than alongside the context itself.
*/
const LocationUrlParamsProvider: FC<SimpleProviderProps> = ({ children }) => {
const urlParams = useUrlParamsFromLocation();
return <UrlParamsProvider value={urlParams}>{children}</UrlParamsProvider>;
};
const BackgroundProvider: FC<SimpleProviderProps> = ({ children }) => {
const { pathname } = useLocation();
const { background } = useUrlParams();
const rootElement = useRootElement();
useEffect(() => {
document
.getElementsByTagName("body")[0]
.setAttribute("data-background", background);
}, [pathname, background]);
rootElement.setAttribute("data-background", background);
}, [pathname, background, rootElement]);
return children;
};
@@ -57,61 +74,87 @@ const ThemeProvider: FC<SimpleProviderProps> = ({ children }) => {
return children;
};
/** Wraps the app in an {@link AppBar}, if the params ask for one. */
const MaybeAppBar: FC<SimpleProviderProps> = ({ children }) => {
const { header } = useUrlParams();
return header === HeaderStyle.AppBar ? <AppBar>{children}</AppBar> : children;
};
interface Props {
vm: AppViewModel;
/** A point of access to the widget API, if running as a widget. */
widget: WidgetHelpers | null;
}
export const App: FC<Props> = ({ vm }) => {
export const App: FC<Props> = ({ vm, widget }) => {
// The standalone build has no host; the widget build's host is the client it
// is a widget of.
const hostBridge = useInitial(() =>
widget === null ? nullHostBridge : createWidgetHostBridge(widget),
);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
Initializer.init()
?.then(async () => {
if (loaded) return;
setLoaded(true);
await widget?.api.sendContentLoaded();
await hostBridge.contentLoaded();
})
.catch(logger.error);
});
// Since we are outside the router component, we cannot use useUrlParams here
const { header } = useMemo(getUrlParams, []);
const content = loaded ? (
<ClientProvider>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} widget={widget} />}
>
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
// As a widget, the client comes from the host over the widget API. Standalone,
// Element Call finds one itself, so there is nothing to wait for here.
const [widgetClient, setWidgetClient] = useState<MatrixClient | undefined>(
undefined,
);
useEffect(() => {
if (widget === null) return;
widget.client
.then(setWidgetClient)
.catch((e) => logger.error("Failed to obtain the host's client", e));
}, [widget]);
const clientReady = widget === null || widgetClient !== undefined;
const content =
loaded && clientReady ? (
<ClientProvider client={widgetClient}>
<MediaDevicesContext value={vm.mediaDevices}>
<ProcessorProvider>
<Sentry.ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
>
<Routes>
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
) : (
<LoadingPage />
);
return (
<BrowserRouter>
<BackgroundProvider>
<ThemeProvider>
<TooltipProvider>
<Suspense fallback={null}>
{header === HeaderStyle.AppBar ? (
<AppBar>{content}</AppBar>
) : (
content
)}
</Suspense>
</TooltipProvider>
</ThemeProvider>
</BackgroundProvider>
</BrowserRouter>
<I18nextProvider i18n={i18n}>
<HostBridgeProvider value={hostBridge}>
<BrowserRouter>
<LocationUrlParamsProvider>
<BackgroundProvider>
<ThemeProvider>
<TooltipProvider>
<Suspense fallback={null}>
<MaybeAppBar>{content}</MaybeAppBar>
</Suspense>
</TooltipProvider>
</ThemeProvider>
</BackgroundProvider>
</LocationUrlParamsProvider>
</BrowserRouter>
</HostBridgeProvider>
</I18nextProvider>
);
};
+14 -49
View File
@@ -9,18 +9,22 @@ import { afterEach, expect, test, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC, type PropsWithChildren } from "react";
import { type WidgetApi } from "matrix-widget-api";
import { ClientContextProvider } from "./ClientContext";
import { Avatar, getAvatarFromWidgetAPI } from "./Avatar";
import { Avatar } from "./Avatar";
import { mockMatrixRoomMember, mockRtcMembership } from "./utils/test";
import { widget } from "./widget";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "./HostBridge";
const TestComponent: FC<
PropsWithChildren<{
client: MatrixClient;
hostBridge?: HostBridge;
}>
> = ({ client, children }) => {
> = ({ client, hostBridge = nullHostBridge, children }) => {
return (
<ClientContextProvider
value={{
@@ -38,17 +42,11 @@ const TestComponent: FC<
},
}}
>
{children}
<HostBridgeProvider value={hostBridge}>{children}</HostBridgeProvider>
</ClientContextProvider>
);
};
vi.mock("./widget", () => ({
widget: {
api: null, // Ideally we'd only mock this in the as a widget test so the whole module is otherwise null, but just nulling `api` by default works well enough
},
}));
afterEach(() => {
vi.unstubAllGlobals();
});
@@ -135,7 +133,7 @@ test("should attempt to fetch authenticated media from the server", async () =>
});
});
test("should attempt to use widget API if running as a widget", async () => {
test("should download media through the host when it offers to", async () => {
const expectedMXCUrl = "mxc://example.org/alice-avatar";
const expectedObjectURL = "my-object-url";
const theBlob = new Blob([]);
@@ -151,8 +149,8 @@ test("should attempt to use widget API if running as a widget", async () => {
getAccessToken: () => undefined,
} as unknown as MatrixClient);
widget!.api = { downloadFile: vi.fn() } as unknown as WidgetApi;
vi.spyOn(widget!.api, "downloadFile").mockResolvedValue({ file: theBlob });
const downloadMedia = vi.fn().mockResolvedValue(theBlob);
const hostBridge: HostBridge = { ...nullHostBridge, downloadMedia };
const member = mockMatrixRoomMember(
mockRtcMembership("@alice:example.org", "AAAA"),
{
@@ -161,7 +159,7 @@ test("should attempt to use widget API if running as a widget", async () => {
);
const displayName = "Alice";
render(
<TestComponent client={client}>
<TestComponent client={client} hostBridge={hostBridge}>
<Avatar
id={member.userId}
name={displayName}
@@ -176,38 +174,5 @@ test("should attempt to use widget API if running as a widget", async () => {
document.querySelector(`img[src='${expectedObjectURL}']`),
);
expect(widget!.api.downloadFile).toBeCalledWith(expectedMXCUrl);
});
test("Supports download files as base64", async () => {
const expectedMXCUrl = "mxc://example.org/alice-avatar";
const expectedBase64 =
"iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAIAAACQkWg2AAADIElEQVR4nAAQA+/8ApxhEfFNuwna" +
"+DO1pFMx5YDg6gb8p1WFkbFSox9H6r5c8jp1gxlHXrDfA/oQFi4A0gTXH9YBNgwRm12xO68QP6lv" +
"ZLKH9qW1VM6kz6zA3T1Ui8J+Xbnh2BZ7oXDe/2gajzoA6j1JGotpz99xO+T2NR634Nhx3zhuera/" +
"UdrpMLdEpwWXLnSqZRasGsrl93FjdTwRBMaqsx6vJksnPOmV9ttbXFIOb0XDGPbVythSC2n7P/bS" +
"Zv0U0QqbBLk/5Wu1werYzAHiz11Bj8bEylQ92Pxvo+PwF6/KbGnIHTvGZkFzDkMnqz3g7Pw3NOSP" +
"oV+qfyJuSI0AeZmrPejFQ8kzBSDWO8D7lr4+6ePRBRmZtKCf+fNjSCOyb5jqwhBnD2cycbJtQQbR" +
"A4qdPG2ONfTPeQgi96+zT7grBI0JwvgFBceJdLJd4BX1VQIyY+j7OYueNWqEpf8iYgMj78I95eRt" +
"nfPLwlxhVns84iL4Yvw8jDrB9vQi8ktpsdJOMiDwKrBGD3q56COD2oIA96CCBgiro4tkvkumZSAc" +
"ZKXRLsziUFGytWJLaPjwnzXv2hicPy6k9AXsF3QkysOZAkB3m9XPpixhq9b0OKqV/zZx3L79o6wZ" +
"Dr40J7sj7f+ARd545CP01r5omHt94tbnjgA46HsM2OhP+qQ882LN+Bhscq2WSHGSHT4J9MQcsWZP" +
"2+N2LdPy61MN4/1++BJHmDcDLQBUEwLvjZp1fRfzxV7yirwIiOA7Vr8z+1yvS/pSkfUzkjswybOd" +
"M5i0I8Q69MTXAKxqtR0/tyGkfCmHfupGASp/SAT9J8f3aQV+gDbpva592v4w8Cv5EMm7CzZPwThF" +
"kgTChNPts7F03ccxpblfIz0EiAON1DKk71rX07BvDlLHY1ItPuqZ7hjy19jrAgl+QqEE1btHVA5R" +
"uAnRXpEWc6rjARlJY5G1wbMk12rrqpr8rhR3YpFgLgOx4BtQ0D/hGe7KANSGBMQojmObId0asCmd" +
"XzmnQI9P8QnwsO9vtqZlgIoU4g+f2/G8Q3/nVMX7dujniwEAAP//KmiQs7P8MeIAAAAASUVORK5C" +
"YII=";
const mockWidgetAPI = {
downloadFile: vi.fn().mockImplementation(async (contentUri) => {
if (contentUri !== expectedMXCUrl) {
return Promise.reject(new Error("Unexpected content URI"));
}
return { file: expectedBase64 };
}),
} as unknown as WidgetApi;
const blob = await getAvatarFromWidgetAPI(mockWidgetAPI, expectedMXCUrl);
expect(blob).toBeInstanceOf(Blob);
expect(downloadMedia).toBeCalledWith(expectedMXCUrl);
});
+7 -27
View File
@@ -14,10 +14,9 @@ import {
} from "react";
import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { type WidgetApi } from "matrix-widget-api";
import { useClientState } from "./ClientContext";
import { widget } from "./widget";
import { useHostBridge } from "./HostBridge";
export enum Size {
XS = "xs",
@@ -76,6 +75,7 @@ export const Avatar: FC<Props> = ({
...props
}) => {
const clientState = useClientState();
const hostBridge = useHostBridge();
const sizePx = useMemo(
() =>
@@ -87,7 +87,8 @@ export const Avatar: FC<Props> = ({
const [avatarUrl, setAvatarUrl] = useState<string | undefined>(undefined);
// In theory, a change in `clientState` or `sizePx` could run extra getAvatarFromWidgetAPI calls, but in practice they should be stable long before this code runs.
// In theory, a change in `clientState` or `sizePx` could run extra media
// downloads, but in practice they should be stable long before this code runs.
useEffect(() => {
if (!src) {
setAvatarUrl(undefined);
@@ -96,8 +97,8 @@ export const Avatar: FC<Props> = ({
let blob: Promise<Blob>;
if (widget?.api) {
blob = getAvatarFromWidgetAPI(widget.api, src);
if (hostBridge.downloadMedia) {
blob = hostBridge.downloadMedia(src);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
@@ -132,7 +133,7 @@ export const Avatar: FC<Props> = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [clientState, src, sizePx]);
}, [clientState, hostBridge, src, sizePx]);
return (
<CompoundAvatar
@@ -172,24 +173,3 @@ async function getAvatarFromServer(
return blob;
}
// export for testing
export async function getAvatarFromWidgetAPI(
api: WidgetApi,
src: string,
): Promise<Blob> {
const response = await api.downloadFile(src);
const file = response.file;
// element-web sends a Blob, and the MSC4039 is considering changing the spec to strictly Blob, so only handling that
if (file instanceof Blob) {
return file;
} else if (typeof file === "string") {
// it is a base64 string
const bytes = Uint8Array.from(atob(file), (c) => c.charCodeAt(0));
return new Blob([bytes]);
}
throw new Error(
"Downloaded file format is not supported: " + typeof file + "",
);
}
+115
View File
@@ -0,0 +1,115 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, vi } from "vitest";
import { render, waitFor } from "@testing-library/react";
import { BrowserRouter } from "react-router-dom";
import { type MatrixClient } from "matrix-js-sdk";
import { type FC } from "react";
import { ClientProvider, useClientState } from "./ClientContext";
const mockClient = (userId = "@alice:example.org"): MatrixClient =>
({
on: vi.fn(),
removeListener: vi.fn(),
getUserId: () => userId,
getDeviceId: () => "AAAA",
stopClient: vi.fn(),
}) as Partial<MatrixClient> as MatrixClient;
/** Reports what the context says, so a test can assert on it. */
const ShowClientState: FC = () => {
const state = useClientState();
if (state === undefined) return <span>loading</span>;
if (state.state === "error") return <span>error</span>;
return (
<span>{state.authenticated?.client.getUserId() ?? "unauthenticated"}</span>
);
};
test("uses a client supplied by the host without waiting", () => {
const client = mockClient();
const { container } = render(
<BrowserRouter>
<ClientProvider client={client}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
// Available on the very first render: a supplied client needs no session
// restoring, so there is no loading state to pass through.
expect(container.textContent).toBe("@alice:example.org");
});
test("does not claim exclusive use of storage when given a client", () => {
// The channel is created when the module loads, so spy on the prototype
// rather than trying to replace the global.
const postMessage = vi.spyOn(BroadcastChannel.prototype, "postMessage");
render(
<BrowserRouter>
<ClientProvider client={mockClient()}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
// The broadcast shuts down other instances to protect Element Call's own
// stores. A host's client brings its own, so there is nothing to protect.
expect(postMessage).not.toHaveBeenCalled();
postMessage.mockRestore();
});
test("follows the client when the host swaps it", () => {
const first = mockClient();
const second = mockClient("@bob:example.org");
const { container, rerender } = render(
<BrowserRouter>
<ClientProvider client={first}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
expect(container.textContent).toBe("@alice:example.org");
// A host that re-authenticates hands us a new client on a mounted component
rerender(
<BrowserRouter>
<ClientProvider client={second}>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
expect(container.textContent).toBe("@bob:example.org");
});
test("finds a client of its own when the host supplies none", async () => {
const client = mockClient();
vi.doMock("./utils/spa", () => ({
initSPA: vi.fn().mockResolvedValue({ client, passwordlessUser: true }),
}));
const { container } = render(
<BrowserRouter>
<ClientProvider>
<ShowClientState />
</ClientProvider>
</BrowserRouter>,
);
// Nothing to show until a session has been restored or created
expect(container.textContent).toBe("loading");
await waitFor(() => expect(container.textContent).toBe("@alice:example.org"));
vi.doUnmock("./utils/spa");
});
+51 -49
View File
@@ -21,9 +21,8 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import type { WidgetApi } from "matrix-widget-api";
import { ErrorPage } from "./FullScreenView";
import { widget } from "./widget";
import { useHostBridge } from "./HostBridge";
import {
PosthogAnalytics,
RegistrationType,
@@ -134,18 +133,48 @@ const loadChannel =
interface Props {
children: JSX.Element;
/**
* The client Element Call should use.
*
* When a host embeds Element Call it already has a client, and owns the
* user's session; supplying it here means Element Call neither authenticates
* anyone nor manages their session. Left out, Element Call finds a client
* itself — from the widget API, or by restoring or creating a session of its
* own.
*/
client?: MatrixClient;
}
export const ClientProvider: FC<Props> = ({ children }) => {
export const ClientProvider: FC<Props> = ({ children, client }) => {
const navigate = useNavigate();
const hostBridge = useHostBridge();
// null = signed out, undefined = loading
const [initClientState, setInitClientState] = useState<
InitResult | null | undefined
>(undefined);
>(
client === undefined
? undefined
: // A supplied client belongs to the host, so there is no session of ours
// to restore and nothing to wait for.
{ client, passwordlessUser: false },
);
const initializing = useRef(false);
useEffect(() => {
if (client !== undefined) {
// Nothing to load, but a host may hand us a different client later — on
// re-authenticating, say — so follow whichever one it has given us.
setInitClientState((current) =>
current?.client === client
? current
: { client, passwordlessUser: false },
);
// Analytics still need to follow the user's choices.
if (PosthogAnalytics.instance.isEnabled())
PosthogAnalytics.instance.startListeningToSettingsChanges();
return;
}
// In case the component is mounted, unmounted, and remounted quickly (as
// React does in strict mode), we need to make sure not to doubly initialize
// the client.
@@ -160,7 +189,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
})
.catch((err) => logger.error(err))
.finally(() => (initializing.current = false));
}, []);
}, [client]);
const changePassword = useCallback(
async (password: string) => {
@@ -201,7 +230,6 @@ export const ClientProvider: FC<Props> = ({ children }) => {
saveSession(session);
setInitClientState({
widgetApi: null,
client,
passwordlessUser: session.passwordlessUser,
});
@@ -228,11 +256,13 @@ export const ClientProvider: FC<Props> = ({ children }) => {
// To protect against multiple sessions writing to the same storage
// simultaneously, we send a broadcast message that shuts down all other
// running instances of the app. This isn't necessary if the app is running in
// a widget though, since then it'll be mostly stateless.
// running instances of the app. Element Call only has storage of its own to
// protect when it created the session itself; given a client — by a host, or
// over the widget API — it is mostly stateless.
const ownsSession = client === undefined;
useEffect(() => {
if (!widget) loadChannel?.postMessage({});
}, []);
if (ownsSession) loadChannel?.postMessage({});
}, [ownsSession]);
const [alreadyOpenedErr, setAlreadyOpenedErr] = useState<Error | undefined>(
undefined,
@@ -307,64 +337,36 @@ export const ClientProvider: FC<Props> = ({ children }) => {
initClientState.client.on(ClientEvent.Sync, onSync);
}
if (initClientState.widgetApi) {
const reactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.reaction",
);
const redactSend = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.send.event:m.room.redaction",
);
const reactRcv = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.receive.event:m.reaction",
);
const redactRcv = initClientState.widgetApi.hasCapability(
"org.matrix.msc2762.receive.event:m.room.redaction",
);
if (!reactSend || !reactRcv || !redactSend || !redactRcv) {
logger.warn("Widget does not support reactions");
setSupportsReactions(false);
} else {
setSupportsReactions(true);
}
} else {
setSupportsReactions(true);
}
if (!hostBridge.supportsReactions)
logger.warn("The host does not permit reactions");
setSupportsReactions(hostBridge.supportsReactions);
return (): void => {
if (initClientState.client) {
initClientState.client.removeListener(ClientEvent.Sync, onSync);
}
};
}, [initClientState, onSync]);
}, [initClientState, onSync, hostBridge]);
if (alreadyOpenedErr) {
return <ErrorPage widget={widget} error={alreadyOpenedErr} />;
return <ErrorPage error={alreadyOpenedErr} />;
}
return <ClientContext value={state}>{children}</ClientContext>;
};
export type InitResult = {
widgetApi: WidgetApi | null;
client: MatrixClient;
passwordlessUser: boolean;
};
/**
* Restores or creates a session of Element Call's own. Only reached when no
* client was supplied for it to use.
*/
async function loadClient(): Promise<InitResult | null> {
if (widget) {
// We're inside a widget, so let's engage *matryoshka mode*
logger.log("Using a matryoshka client");
const client = await widget.client;
return {
widgetApi: widget.api,
client,
passwordlessUser: false,
};
} else {
const { initSPA } = await import("./utils/spa");
return initSPA(loadSession, clearSession);
}
const { initSPA } = await import("./utils/spa");
return initSPA(loadSession, clearSession);
}
export interface Session {
+13 -19
View File
@@ -21,7 +21,7 @@ import { RageshakeButton } from "./settings/RageshakeButton";
import styles from "./ErrorView.module.css";
import { useUrlParams } from "./UrlParams";
import { LinkButton } from "./button";
import { ElementWidgetActions, type WidgetHelpers } from "./widget.ts";
import { useHostBridge } from "./HostBridge.ts";
interface Props {
Icon: ComponentType<SVGAttributes<SVGElement>>;
@@ -38,7 +38,6 @@ interface Props {
*/
fatal?: boolean;
children: ReactNode;
widget: WidgetHelpers | null;
}
export const ErrorView: FC<Props> = ({
@@ -47,32 +46,27 @@ export const ErrorView: FC<Props> = ({
rageshake,
fatal,
children,
widget,
}) => {
const { t } = useTranslation();
const { confineToRoom } = useUrlParams();
const hostBridge = useHostBridge();
const onReload = useCallback(() => {
window.location.href = "/";
}, []);
const CloseWidgetButton: FC<{ widget: WidgetHelpers }> = ({
widget,
const CloseButton: FC<{ close: () => Promise<void> }> = ({
close,
}): ReactElement => {
// in widget mode we don't want to show the return home button but a close button
const closeWidget = (): void => {
widget.api.transport
.send(ElementWidgetActions.Close, {})
.catch((e) => {
// What to do here?
logger.error("Failed to send close action", e);
})
.finally(() => {
widget.api.transport.stop();
});
// When the host can dismiss us, offer that instead of a link home
const onClose = (): void => {
close().catch((e) => {
// What to do here?
logger.error("Failed to ask the host to close Element Call", e);
});
};
return (
<Button kind="primary" onClick={closeWidget}>
<Button kind="primary" onClick={onClose}>
{t("action.close")}
</Button>
);
@@ -108,8 +102,8 @@ export const ErrorView: FC<Props> = ({
{rageshake && (
<RageshakeButton description={`***Error View***: ${title}`} />
)}
{widget ? (
<CloseWidgetButton widget={widget} />
{hostBridge.close ? (
<CloseButton close={hostBridge.close} />
) : (
!confineToRoom && <ReturnToHomeButton />
)}
+1 -4
View File
@@ -17,7 +17,6 @@ import styles from "./FullScreenView.module.css";
import { useUrlParams } from "./UrlParams";
import { RichError } from "./RichError";
import { ErrorView } from "./ErrorView";
import { type WidgetHelpers } from "./widget.ts";
interface FullScreenViewProps {
className?: string;
@@ -48,12 +47,11 @@ export const FullScreenView: FC<FullScreenViewProps> = ({
interface ErrorPageProps {
error: unknown;
widget: WidgetHelpers | null;
}
// Due to this component being used as the crash fallback for Sentry, which has
// weird type requirements, we can't just give this a type of FC<ErrorPageProps>
export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
export const ErrorPage = ({ error }: ErrorPageProps): ReactElement => {
const { t } = useTranslation();
useEffect(() => {
logger.error(error);
@@ -66,7 +64,6 @@ export const ErrorPage = ({ error, widget }: ErrorPageProps): ReactElement => {
error.richMessage
) : (
<ErrorView
widget={widget}
Icon={ErrorSolidIcon}
title={t("error.generic")}
rageshake
+288
View File
@@ -0,0 +1,288 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { type WidgetApi, WidgetApiToWidgetAction } from "matrix-widget-api";
import EventEmitter from "events";
import { type Observable } from "rxjs";
import {
createWidgetHostBridge,
type HostBridge,
nullHostBridge,
} from "./HostBridge";
import { ElementWidgetActions, type WidgetHelpers } from "./widget";
function mockWidget(api: Partial<WidgetApi>): WidgetHelpers {
return {
api: api as WidgetApi,
lazyActions: new EventEmitter(),
client: Promise.resolve(),
} as unknown as WidgetHelpers;
}
/** A widget whose transport records what Element Call sends it. */
function mockTransport(): {
send: ReturnType<typeof vi.fn>;
reply: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
} {
return {
send: vi.fn().mockResolvedValue(undefined),
reply: vi.fn(),
stop: vi.fn(),
};
}
describe("createWidgetHostBridge", () => {
describe("telling the host what Element Call is doing", () => {
test("asks to be kept on screen, and to stop being", async () => {
const setAlwaysOnScreen = vi.fn().mockResolvedValue(true);
const bridge = createWidgetHostBridge(mockWidget({ setAlwaysOnScreen }));
await bridge.setAlwaysOnScreen(true);
await bridge.setAlwaysOnScreen(false);
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(1, true);
expect(setAlwaysOnScreen).toHaveBeenNthCalledWith(2, false);
});
test("reports that it has loaded", async () => {
const sendContentLoaded = vi.fn().mockResolvedValue(undefined);
const bridge = createWidgetHostBridge(mockWidget({ sendContentLoaded }));
await bridge.contentLoaded();
expect(sendContentLoaded).toHaveBeenCalledOnce();
});
test.each([
["notifyJoined", ElementWidgetActions.JoinCall, {}],
["notifyHungUp", ElementWidgetActions.HangupCall, {}],
] as const)("sends %s as %s", async (method, action, payload) => {
const transport = mockTransport();
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
await bridge[method]();
expect(transport.send).toHaveBeenCalledWith(action, payload);
});
test("sends the mute state the host needs to mirror", async () => {
const transport = mockTransport();
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
await bridge.notifyDeviceMute({
audio_enabled: true,
video_enabled: false,
});
expect(transport.send).toHaveBeenCalledWith(
ElementWidgetActions.DeviceMute,
{ audio_enabled: true, video_enabled: false },
);
});
});
describe("relaying what the host asks for", () => {
/** Emits a widget action the way widget.ts does, and returns the event. */
function askHost(
widget: WidgetHelpers,
action: string,
data: unknown,
): CustomEvent {
const ev = new CustomEvent(action, { detail: { action, data } });
widget.lazyActions.emit(action, ev);
return ev;
}
// Selectors rather than keys, so each stream keeps its own request type
const inboundStreams: [
name: string,
select: (bridge: HostBridge) => Observable<{ data: unknown }>,
action: string,
][] = [
[
"themeChange$",
(bridge) => bridge.themeChange$,
WidgetApiToWidgetAction.ThemeChange,
],
["join$", (bridge) => bridge.join$, ElementWidgetActions.JoinCall],
["hangUp$", (bridge) => bridge.hangUp$, ElementWidgetActions.HangupCall],
[
"deviceMute$",
(bridge) => bridge.deviceMute$,
ElementWidgetActions.DeviceMute,
],
];
test.each(inboundStreams)(
"surfaces %s with the host's data",
(_name, select, action) => {
const widget = mockWidget({ transport: mockTransport() } as never);
const bridge = createWidgetHostBridge(widget);
const seen: unknown[] = [];
select(bridge).subscribe((request) => seen.push(request.data));
askHost(widget, action, { some: "payload" });
expect(seen).toEqual([{ some: "payload" }]);
},
);
test("replies to the host against the request it made", () => {
const transport = mockTransport();
const widget = mockWidget({ transport } as never);
const bridge = createWidgetHostBridge(widget);
bridge.deviceMute$.subscribe((request) =>
request.reply({ audio_enabled: false, video_enabled: true }),
);
const ev = askHost(widget, ElementWidgetActions.DeviceMute, {
audio_enabled: false,
});
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {
audio_enabled: false,
video_enabled: true,
});
});
test("still replies when there is nothing to say", () => {
const transport = mockTransport();
const widget = mockWidget({ transport } as never);
const bridge = createWidgetHostBridge(widget);
bridge.hangUp$.subscribe((request) => request.reply());
const ev = askHost(widget, ElementWidgetActions.HangupCall, {});
// The widget API requires an answer, so an empty reply becomes {}
expect(transport.reply).toHaveBeenCalledWith(ev.detail, {});
});
test("stops listening once unsubscribed", () => {
const widget = mockWidget({ transport: mockTransport() } as never);
const bridge = createWidgetHostBridge(widget);
const seen: unknown[] = [];
const subscription = bridge.hangUp$.subscribe((r) => seen.push(r.data));
subscription.unsubscribe();
askHost(widget, ElementWidgetActions.HangupCall, {});
expect(seen).toEqual([]);
});
});
describe("downloadMedia", () => {
const mxcUri = "mxc://example.org/alice-avatar";
test("passes a Blob through unchanged", async () => {
const file = new Blob([]);
const bridge = createWidgetHostBridge(
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file }) }),
);
await expect(bridge.downloadMedia!(mxcUri)).resolves.toBe(file);
});
test("decodes a base64 string into a Blob", async () => {
const bridge = createWidgetHostBridge(
mockWidget({
// "hello" in base64
downloadFile: vi.fn().mockResolvedValue({ file: "aGVsbG8=" }),
}),
);
const blob = await bridge.downloadMedia!(mxcUri);
expect(blob).toBeInstanceOf(Blob);
// The five decoded bytes, rather than the eight characters of base64 —
// which is what we'd get if the string were stored verbatim.
expect(blob.size).toBe(5);
});
test("rejects a file format it does not understand", async () => {
const bridge = createWidgetHostBridge(
mockWidget({ downloadFile: vi.fn().mockResolvedValue({ file: 42 }) }),
);
await expect(bridge.downloadMedia!(mxcUri)).rejects.toThrow(
"Downloaded file format is not supported",
);
});
});
describe("close", () => {
test("asks the host to close, then stops the transport", async () => {
const transport = {
send: vi.fn().mockResolvedValue(undefined),
stop: vi.fn(),
};
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
await bridge.close!();
expect(transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
{},
);
expect(transport.stop).toHaveBeenCalledOnce();
});
test("stops the transport even when the host refuses to close", async () => {
const transport = {
send: vi.fn().mockRejectedValue(new Error("no")),
stop: vi.fn(),
};
const bridge = createWidgetHostBridge(mockWidget({ transport } as never));
// Leaving the messaging live would leave the close affordance dead
await expect(bridge.close!()).rejects.toThrow("no");
expect(transport.stop).toHaveBeenCalledOnce();
});
});
describe("supportsReactions", () => {
const capabilities = [
"org.matrix.msc2762.send.event:m.reaction",
"org.matrix.msc2762.send.event:m.room.redaction",
"org.matrix.msc2762.receive.event:m.reaction",
"org.matrix.msc2762.receive.event:m.room.redaction",
];
test("is true when the host grants every reaction capability", () => {
const bridge = createWidgetHostBridge(
mockWidget({ hasCapability: () => true }),
);
expect(bridge.supportsReactions).toBe(true);
});
test.each(capabilities)("is false without %s", (missing) => {
const bridge = createWidgetHostBridge(
mockWidget({ hasCapability: (c) => c !== missing }),
);
expect(bridge.supportsReactions).toBe(false);
});
});
});
describe("nullHostBridge", () => {
test("offers no way to close, so the interface falls back to navigation", () => {
expect(nullHostBridge.close).toBeUndefined();
});
test("offers no media download, so Element Call uses its own client", () => {
expect(nullHostBridge.downloadMedia).toBeUndefined();
});
test("supports reactions, since nothing is mediating its homeserver access", () => {
expect(nullHostBridge.supportsReactions).toBe(true);
});
});
+220
View File
@@ -0,0 +1,220 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { createContext, use } from "react";
import { fromEvent, map, NEVER, type Observable } from "rxjs";
import {
type IWidgetApiRequest,
type IWidgetApiRequestData,
WidgetApiToWidgetAction,
} from "matrix-widget-api";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "./widget";
// Note: these are type aliases rather than interfaces so that they satisfy the
// widget API's index-signature payload types.
/** The mute state Element Call and its host exchange. */
export type DeviceMuteState = {
audio_enabled: boolean;
video_enabled: boolean;
};
/**
* A mute state change requested by the host. An absent field means "leave this
* one as it is".
*/
export type DeviceMuteRequest = {
audio_enabled?: boolean;
video_enabled?: boolean;
};
/**
* Something the host has asked of Element Call, which it is expected to
* acknowledge.
*/
export interface HostRequest<Data, Reply = void> {
data: Data;
/** Acknowledges the request. Should be called exactly once. */
reply(reply: Reply): void;
}
/**
* Element Call's view of the application hosting it.
*
* Element Call can run as its own page, as a widget inside a Matrix client, or
* embedded directly into one. Only the last two give it a host, and each of
* them reaches it by a different route — so everything Element Call needs from
* whatever is hosting it goes through this interface, rather than being
* expressed in terms of the widget API.
*
* This covers the interactions Element Call has with its host while running.
* The Matrix client it talks to is supplied separately, at startup.
*/
export interface HostBridge {
// What Element Call tells the host.
/**
* Asks the host to keep Element Call on screen (or stop doing so), so that a
* call in progress is not torn down when the user navigates elsewhere.
*/
setAlwaysOnScreen(alwaysOnScreen: boolean): Promise<void>;
/** Tells the host that Element Call has finished loading. */
contentLoaded(): Promise<void>;
/** Tells the host that the user has joined the call. */
notifyJoined(): Promise<void>;
/** Tells the host that the user has hung up. */
notifyHungUp(): Promise<void>;
/** Tells the host the user's current audio and video mute state. */
notifyDeviceMute(state: DeviceMuteState): Promise<void>;
/**
* Asks the host to close Element Call, and stops communicating with it. No
* further calls should be made on this bridge afterwards.
*
* Absent when the host has no way to dismiss Element Call — standalone, the
* user navigates away instead — so its presence is what tells the interface
* whether to offer a close affordance.
*/
close?(): Promise<void>;
// What the host asks of Element Call.
/** The host has changed the theme Element Call should use. */
themeChange$: Observable<HostRequest<{ name?: string }>>;
/** The host wants a preloaded Element Call to join the call now. */
join$: Observable<HostRequest<JoinCallData>>;
/** The host wants Element Call to leave the call. */
hangUp$: Observable<HostRequest<Record<string, never>>>;
/** The host wants to change, or read back, the device mute state. */
deviceMute$: Observable<HostRequest<DeviceMuteRequest, DeviceMuteState>>;
// What the host is capable of.
/** Whether the host permits Element Call to send and receive reactions. */
readonly supportsReactions: boolean;
/**
* Fetches media on Element Call's behalf, for hosts that do not give it
* direct access to the homeserver. Absent when Element Call should fetch
* media itself using its own client.
*/
downloadMedia?(mxcUri: string): Promise<Blob>;
}
/**
* A bridge to nowhere, for when Element Call has no host — that is, when it is
* running as its own page and talks to the homeserver directly.
*/
export const nullHostBridge: HostBridge = {
setAlwaysOnScreen: async () => {},
contentLoaded: async () => {},
notifyJoined: async () => {},
notifyHungUp: async () => {},
notifyDeviceMute: async () => {},
themeChange$: NEVER,
join$: NEVER,
hangUp$: NEVER,
deviceMute$: NEVER,
// Standalone Element Call reaches the homeserver itself, so nothing is
// withholding these from it.
supportsReactions: true,
};
/** Bridges to a host that Element Call is a widget of. */
export function createWidgetHostBridge(widget: WidgetHelpers): HostBridge {
const requests = <Data, Reply>(
action: string,
): Observable<HostRequest<Data, Reply>> =>
(
fromEvent(widget.lazyActions, action) as Observable<
CustomEvent<IWidgetApiRequest>
>
).pipe(
map((ev) => ({
data: ev.detail.data as Data,
// The widget API requires a reply for every request, and carries the
// payload as a plain object, so an empty reply becomes {}.
reply: (reply: Reply): void =>
widget.api.transport.reply(ev.detail, reply ?? {}),
})),
);
const send = async (
action: ElementWidgetActions,
data: IWidgetApiRequestData = {},
): Promise<void> => {
await widget.api.transport.send(action, data);
};
return {
setAlwaysOnScreen: async (alwaysOnScreen) => {
await widget.api.setAlwaysOnScreen(alwaysOnScreen);
},
contentLoaded: async () => widget.api.sendContentLoaded(),
notifyJoined: async () => send(ElementWidgetActions.JoinCall),
notifyHungUp: async () => send(ElementWidgetActions.HangupCall),
notifyDeviceMute: async (state) =>
send(ElementWidgetActions.DeviceMute, state),
close: async () => {
try {
await send(ElementWidgetActions.Close);
} finally {
// Stop regardless of whether the host acknowledged the request. A host
// that rejects or never answers would otherwise leave the messaging
// live, and the close affordance doing nothing at all.
widget.api.transport.stop();
}
},
themeChange$: requests(WidgetApiToWidgetAction.ThemeChange),
join$: requests(ElementWidgetActions.JoinCall),
hangUp$: requests(ElementWidgetActions.HangupCall),
deviceMute$: requests(ElementWidgetActions.DeviceMute),
// Element Call needs the host's permission to send reactions on its behalf.
// Read on access rather than up front: the widget API negotiates its
// capabilities asynchronously, and the bridge is built before that settles.
get supportsReactions(): boolean {
return (
widget.api.hasCapability("org.matrix.msc2762.send.event:m.reaction") &&
widget.api.hasCapability(
"org.matrix.msc2762.send.event:m.room.redaction",
) &&
widget.api.hasCapability(
"org.matrix.msc2762.receive.event:m.reaction",
) &&
widget.api.hasCapability(
"org.matrix.msc2762.receive.event:m.room.redaction",
)
);
},
downloadMedia: async (mxcUri) => {
const { file } = await widget.api.downloadFile(mxcUri);
if (file instanceof Blob) return file;
if (typeof file === "string")
// it is a base64 string
return new Blob([Uint8Array.from(atob(file), (c) => c.charCodeAt(0))]);
throw new Error(
`Downloaded file format is not supported: ${typeof file}`,
);
},
};
}
const HostBridgeContext = createContext<HostBridge | null>(null);
export const HostBridgeProvider = HostBridgeContext.Provider;
/**
* The application hosting Element Call.
*
* Defaults to {@link nullHostBridge}, so that tests and stories, which have no
* host, need no provider.
*/
export const useHostBridge = (): HostBridge =>
use(HostBridgeContext) ?? nullHostBridge;
+4 -2
View File
@@ -24,6 +24,7 @@ import { Heading, Glass } from "@vector-im/compound-web";
import styles from "./Modal.module.css";
import overlayStyles from "./Overlay.module.css";
import { useMediaQuery } from "./useMediaQuery";
import { useRootElement } from "./RootElementContext";
export interface Props {
title: string;
@@ -78,6 +79,7 @@ export const Modal: FC<Props> = ({
...rest
}) => {
const { t } = useTranslation();
const rootElement = useRootElement();
// Empirically, Chrome on Android can end up not matching (hover: none), but
// still matching (pointer: coarse) :/
const touchscreen = useMediaQuery("(hover: none) or (pointer: coarse)");
@@ -100,7 +102,7 @@ export const Modal: FC<Props> = ({
onOpenChange={onOpenChange}
dismissible={onDismiss !== undefined}
>
<Drawer.Portal>
<Drawer.Portal container={rootElement}>
<Drawer.Overlay className={classNames(overlayStyles.bg)} />
<Drawer.Content
className={classNames(
@@ -155,7 +157,7 @@ export const Modal: FC<Props> = ({
return (
<DialogRoot open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogPortal container={rootElement}>
<DialogOverlay
className={classNames(overlayStyles.bg, overlayStyles.animate)}
/>
+2 -1
View File
@@ -8,7 +8,7 @@ Please see LICENSE in the repository root for full details.
import { type FC, useEffect, useState } from "react";
import { toDataURL } from "qrcode";
import classNames from "classnames";
import { t } from "i18next";
import { useTranslation } from "react-i18next";
import styles from "./QrCode.module.css";
@@ -18,6 +18,7 @@ interface Props {
}
export const QrCode: FC<Props> = ({ data, className }) => {
const { t } = useTranslation();
const [url, setUrl] = useState<string | null>(null);
useEffect(() => {
+1 -6
View File
@@ -10,7 +10,6 @@ import { PopOutIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import type { FC, ReactNode } from "react";
import { ErrorView } from "./ErrorView";
import { widget } from "./widget.ts";
/**
* An error consisting of a terse message to be logged to the console and a
@@ -32,11 +31,7 @@ const OpenElsewhere: FC = () => {
const { t } = useTranslation();
return (
<ErrorView
widget={widget}
Icon={PopOutIcon}
title={t("error.open_elsewhere")}
>
<ErrorView Icon={PopOutIcon} title={t("error.open_elsewhere")}>
<p>
{t("error.open_elsewhere_description", {
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
+39
View File
@@ -0,0 +1,39 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { createContext, use } from "react";
/**
* The element that Element Call treats as the root of its own interface.
*
* Element Call decorates this element with the theme, layout and background
* attributes its stylesheets key off, and portals its modals into it. When
* Element Call owns the page this is simply the document body; the intent is
* that when embedded in a host application it becomes the container the host
* mounted it into, so that Element Call does not reach outside its own subtree.
*
* That intent is not yet achievable: several selectors still name `body`
* directly — `body[data-background="gradient"]` and `body[data-platform=…]` in
* `index.css`, and `body[data-platform="ios"]` in `AppBar.module.css` and
* `Modal.module.css` — and `Initializer.initBeforeReact` writes
* `data-platform` straight onto the body. So anything other than the body will
* be decorated correctly and styled incorrectly, silently. Until those are
* scoped, treat this as preparation rather than a working seam.
*/
// No provider is exported yet: nothing supplies a root element, so every
// consumer falls back to the document body. M1 adds one along with the
// component that mounts Element Call into a container.
const RootElementContext = createContext<HTMLElement | null>(null);
/**
* The element Element Call should decorate and portal into.
*
* Defaults to the document body, so that the standalone and widget builds work
* without a provider.
*/
export const useRootElement = (): HTMLElement =>
use(RootElementContext) ?? document.body;
+7 -1
View File
@@ -25,6 +25,7 @@ import { Text } from "@vector-im/compound-web";
import styles from "./Toast.module.css";
import overlayStyles from "./Overlay.module.css";
import { useRootElement } from "./RootElementContext";
interface Props {
/**
@@ -64,6 +65,7 @@ export const Toast: FC<Props> = ({
Icon,
modal = true,
}) => {
const rootElement = useRootElement();
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) onDismiss();
@@ -104,7 +106,11 @@ export const Toast: FC<Props> = ({
return (
<DialogRoot open={open} onOpenChange={onOpenChange} modal={modal}>
{modal ? <DialogPortal>{content}</DialogPortal> : content}
{modal ? (
<DialogPortal container={rootElement}>{content}</DialogPortal>
) : (
content
)}
</DialogRoot>
);
};
+41 -4
View File
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useMemo } from "react";
import { createContext, use, useMemo } from "react";
import { useLocation } from "react-router-dom";
import { logger } from "matrix-js-sdk/lib/logger";
import {
@@ -59,6 +59,17 @@ export interface UrlProperties {
// Widget api related params
widgetId: string | null;
parentUrl: string | null;
/**
* Whether Element Call was started as a widget of a Matrix client, which is
* to say whether it was given a widget ID and a parent to talk to.
*
* Only meaningful to the standalone and widget builds, which own the URL —
* so use it for decisions that belong to the app shell, such as whether
* Element Call is responsible for authenticating the user. Anything the call
* interface itself needs to know about its host should come from the host
* bridge instead.
*/
isWidget: boolean;
/**
* Anything about what room we're pointed to should be from useRoomIdentifier which
* parses the path and resolves alias with respect to the default server name, however
@@ -448,6 +459,7 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
const properties: UrlProperties = {
widgetId,
parentUrl,
isWidget,
// NB. we don't validate roomId here as we do in getRoomIdentifierFromUrl:
// what would we do if it were invalid? If the widget API says that's what
// the room ID is, then that's what it is.
@@ -519,11 +531,36 @@ export const computeUrlParams = (search = "", hash = ""): UrlParams => {
};
};
const UrlParamsContext = createContext<UrlParams | null>(null);
/**
* Hook to simplify use of getUrlParams.
* @returns The app parameters for the current URL
* Supplies the parameters Element Call should run with.
*
* The standalone and widget builds derive these from the URL, but an embedder
* has no URL of its own to put them in, so it provides them directly instead.
*
* TODO: `UrlParams` is no longer an accurate name now that these need not come
* from a URL. Renaming it touches every consumer, so it is left until the rest
* of the de-globalisation work has settled.
*/
export const useUrlParams = (): UrlParams => {
export const UrlParamsProvider = UrlParamsContext.Provider;
/**
* The parameters Element Call is running with.
*
* Falls back to parsing `window.location` when no provider is present, so that
* tests and stories keep working without one.
*/
export const useUrlParams = (): UrlParams =>
use(UrlParamsContext) ?? getUrlParams();
/**
* Derives {@link UrlParams} from the current router location.
*
* Only meaningful when Element Call owns the URL; embedders provide the params
* directly through {@link UrlParamsProvider}.
*/
export const useUrlParamsFromLocation = (): UrlParams => {
const { search, hash } = useLocation();
return useMemo(() => getUrlParams(search, hash), [search, hash]);
};
+177 -55
View File
@@ -15,6 +15,7 @@ import {
afterAll,
} from "vitest";
import posthog, { type CaptureResult } from "posthog-js";
import { type MatrixClient } from "matrix-js-sdk";
import {
Anonymity,
@@ -22,75 +23,122 @@ import {
PosthogAnalytics,
} from "./PosthogAnalytics";
import { mockConfig } from "../utils/test";
import { analyticsConfigFromEnvironment } from "../initializer";
import { optInAnalytics } from "../settings/settings";
describe("PosthogAnalytics", () => {
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
describe("enablement", () => {
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value or URL params", () => {
it("stays off until it is configured", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("ignores config value and does not create instance", () => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
it("stays off when configured without credentials", () => {
PosthogAnalytics.configure({ matrixBackend: "jssdk" });
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("stays off when given only a key", () => {
PosthogAnalytics.configure({
matrixBackend: "jssdk",
apiKey: "api_key",
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("uses URL params if both set", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
it("turns on when given both a key and a host", () => {
PosthogAnalytics.configure({
matrixBackend: "jssdk",
apiKey: "api_key",
apiHost: "https://api.example.com.localhost",
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
// Which of the URL and config.json the credentials come from is a deliberate
// policy: an embedder is responsible for its own users' telemetry, so it must
// not pick up the deployment's, and vice versa.
describe("analyticsConfigFromEnvironment", () => {
beforeEach(() => {
mockConfig({});
window.location.hash = "#";
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
});
it("does not create instance without config value", () => {
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
const urlCredentials = `posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=url_key`;
const configCredentials = {
posthog: {
api_host: "https://config.example.com.localhost",
api_key: "config_key",
},
};
it("ignores URL params and does not create instance", () => {
window.location.hash = `#?posthogApiHost=${encodeURIComponent("https://url.example.com.localhost")}&posthogApiKey=api_key`;
expect(PosthogAnalytics.instance.isEnabled()).toBe(false);
});
it("creates instance with config value", () => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
describe("embedded package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "embedded");
});
expect(PosthogAnalytics.instance.isEnabled()).toBe(true);
it("has no credentials without URL params", () => {
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
});
it("takes the credentials from the URL", () => {
window.location.hash = `#?${urlCredentials}`;
expect(analyticsConfigFromEnvironment()).toMatchObject({
apiKey: "url_key",
apiHost: "https://url.example.com.localhost",
});
});
it("ignores the deployment's config", () => {
mockConfig(configCredentials);
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
});
});
describe("full package", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
it("has no credentials without config", () => {
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
});
it("takes the credentials from the config", () => {
mockConfig(configCredentials);
expect(analyticsConfigFromEnvironment()).toMatchObject({
apiKey: "config_key",
apiHost: "https://config.example.com.localhost",
});
});
it("ignores the URL params", () => {
window.location.hash = `#?${urlCredentials}`;
expect(analyticsConfigFromEnvironment().apiKey).toBeUndefined();
});
});
// Who owns the user's analytics identity depends on how Element Call is
// running, not on which package it was built as.
it("reports the embedded backend when running as a widget", () => {
vi.stubEnv("VITE_PACKAGE", "full");
window.location.hash = `#?widgetId=id&parentUrl=${encodeURIComponent("https://host.example.com.localhost")}&posthogUserId=given_id`;
expect(analyticsConfigFromEnvironment()).toMatchObject({
matrixBackend: "embedded",
hostAnalyticsId: "given_id",
});
});
it("reports the jssdk backend when running standalone", () => {
expect(analyticsConfigFromEnvironment().matrixBackend).toBe("jssdk");
});
});
@@ -204,22 +252,13 @@ describe("PosthogAnalytics", () => {
// posthog-js bumps renaming/removing the hook. The filter logic itself is
// covered by the applyPrivacyFilters block above.
describe("posthog.init wiring", () => {
beforeAll(() => {
vi.stubEnv("VITE_PACKAGE", "full");
});
beforeEach(() => {
mockConfig({
posthog: {
api_host: "https://api.example.com.localhost",
api_key: "api_key",
},
});
PosthogAnalytics.resetInstance();
});
afterAll(() => {
vi.unstubAllEnvs();
PosthogAnalytics.configure({
matrixBackend: "jssdk",
apiKey: "api_key",
apiHost: "https://api.example.com.localhost",
});
});
it("passes events through the privacy filter via before_send", () => {
@@ -244,3 +283,86 @@ describe("PosthogAnalytics", () => {
});
});
});
describe("identifying the user", () => {
const credentials = {
apiKey: "api_key",
apiHost: "https://api.example.com.localhost",
};
function mockClient(accountDataId: string | null): MatrixClient {
return {
isGuest: () => false,
getCrypto: () => undefined,
getAccountDataFromServer: vi
.fn()
.mockResolvedValue(
accountDataId === null ? null : { id: accountDataId },
),
setAccountData: vi.fn().mockResolvedValue({}),
} as Partial<MatrixClient> as MatrixClient;
}
beforeEach(() => {
PosthogAnalytics.resetInstance();
optInAnalytics.setValue(true);
});
it("reports under the ID its host assigned, and stores nothing", async () => {
const client = mockClient(null);
window.matrixclient = client;
PosthogAnalytics.configure({
...credentials,
matrixBackend: "embedded",
hostAnalyticsId: "assigned-by-host",
});
const identify = vi.spyOn(posthog, "identify");
PosthogAnalytics.instance.startListeningToSettingsChanges();
await vi.waitFor(() =>
expect(identify).toHaveBeenCalledWith("assigned-by-host"),
);
// The host owns the user's account, so Element Call must not write to it
expect(client.setAccountData).not.toHaveBeenCalled();
});
it("keeps its own ID in account data when it owns the session", async () => {
const client = mockClient(null);
window.matrixclient = client;
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
PosthogAnalytics.instance.startListeningToSettingsChanges();
// No ID on the server yet, so one is minted and stored for other devices
await vi.waitFor(() => expect(client.setAccountData).toHaveBeenCalled());
});
it("reuses the ID already in account data", async () => {
const client = mockClient("stored-earlier");
window.matrixclient = client;
PosthogAnalytics.configure({ ...credentials, matrixBackend: "jssdk" });
const identify = vi.spyOn(posthog, "identify");
PosthogAnalytics.instance.startListeningToSettingsChanges();
await vi.waitFor(() =>
expect(identify).toHaveBeenCalledWith("stored-earlier"),
);
expect(client.setAccountData).not.toHaveBeenCalled();
});
it("records how it reaches Matrix as a super property", async () => {
window.matrixclient = mockClient("stored-earlier");
PosthogAnalytics.configure({ ...credentials, matrixBackend: "embedded" });
const register = vi.spyOn(posthog, "register");
PosthogAnalytics.instance.startListeningToSettingsChanges();
await vi.waitFor(() =>
expect(register).toHaveBeenCalledWith(
expect.objectContaining({ matrixBackend: "embedded" }),
),
);
});
});
+58 -27
View File
@@ -15,7 +15,6 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk";
import { type Subscription } from "rxjs";
import { widget } from "../widget";
import {
CallEndedTracker,
CallStartedTracker,
@@ -29,8 +28,6 @@ import {
CallConnectDurationTracker,
CallReconnectingTracker,
} from "./PosthogEvents";
import { Config } from "../config/Config";
import { getUrlParams } from "../UrlParams";
import { optInAnalytics } from "../settings/settings";
/* Posthog analytics tracking.
@@ -140,6 +137,27 @@ interface PlatformProperties {
cryptoVersion?: string;
}
/**
* How analytics reporting should be set up, supplied by whoever is starting
* Element Call rather than discovered from the page it happens to be on.
*/
export interface AnalyticsConfig {
/** The PostHog project key. Without one, analytics stay switched off. */
apiKey?: string;
apiHost?: string;
/**
* How Element Call reaches Matrix. When `embedded`, the host owns the user's
* identity: it supplies the analytics ID, and Element Call must not store one
* in the user's account data.
*/
matrixBackend: "embedded" | "jssdk";
/** The analytics ID the host has assigned to this user, when embedded. */
hostAnalyticsId?: string | null;
}
/** Analytics are off until someone asks for them. */
const analyticsDisabled: AnalyticsConfig = { matrixBackend: "jssdk" };
export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
@@ -167,13 +185,32 @@ export class PosthogAnalytics {
private registrationType: RegistrationType = RegistrationType.Guest;
private optInListener: Subscription | null = null;
private static analyticsConfig: AnalyticsConfig = analyticsDisabled;
/**
* Sets up analytics reporting. Must be called before the instance is first
* used; without it, analytics stay switched off.
*/
public static configure(config: AnalyticsConfig): void {
if (this.internalInstance)
// Configuration is read once, when the instance is built, so arriving
// late means analytics are already running unconfigured.
logger.warn(
"Analytics were configured after they had already been started; the new configuration will not take effect",
);
this.analyticsConfig = config;
}
public static hasInstance(): boolean {
return Boolean(this.internalInstance);
}
public static get instance(): PosthogAnalytics {
if (!this.internalInstance) {
this.internalInstance = new PosthogAnalytics(posthog);
this.internalInstance = new PosthogAnalytics(
posthog,
PosthogAnalytics.analyticsConfig,
);
}
return this.internalInstance;
}
@@ -181,20 +218,14 @@ export class PosthogAnalytics {
public static resetInstance(): void {
// Reset the singleton instance
this.internalInstance = null;
this.analyticsConfig = analyticsDisabled;
}
private constructor(private readonly posthog: PostHog) {
let apiKey: string | undefined;
let apiHost: string | undefined;
if (import.meta.env.VITE_PACKAGE === "embedded") {
// for the embedded package we always use the values from the URL as the widget host is responsible for analytics configuration
apiKey = getUrlParams().posthogApiKey ?? undefined;
apiHost = getUrlParams().posthogApiHost ?? undefined;
} else if (import.meta.env.VITE_PACKAGE === "full") {
// in full package it is the server responsible for the analytics
apiKey = Config.get().posthog?.api_key;
apiHost = Config.get().posthog?.api_host;
}
private constructor(
private readonly posthog: PostHog,
private readonly config: AnalyticsConfig,
) {
const { apiKey, apiHost } = config;
if (apiKey && apiHost) {
const beforeSend = (event: CaptureResult | null): CaptureResult | null =>
@@ -225,15 +256,15 @@ export class PosthogAnalytics {
}
}
private static getPlatformProperties(): PlatformProperties {
private getPlatformProperties(): PlatformProperties {
const appVersion = import.meta.env.VITE_APP_VERSION || "dev";
return {
appVersion,
matrixBackend: widget ? "embedded" : "jssdk",
matrixBackend: this.config.matrixBackend,
callBackend: "livekit",
cryptoVersion: widget
? undefined
: window.matrixclient?.getCrypto()?.getVersion(),
// Undefined when Element Call has no crypto of its own, which is the case
// whenever a host is doing the encrypting for it.
cryptoVersion: window.matrixclient?.getCrypto()?.getVersion(),
};
}
@@ -283,8 +314,8 @@ export class PosthogAnalytics {
// different devices to send the same ID.
let analyticsID = await this.getAnalyticsId();
try {
if (!analyticsID && !widget) {
// only try setting up a new analytics ID in the standalone app.
if (!analyticsID && this.config.matrixBackend !== "embedded") {
// only mint an analytics ID when we are the ones storing it.
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
// Note there's a race condition here - if two devices do these steps at the same time, last write
@@ -313,8 +344,8 @@ export class PosthogAnalytics {
private async getAnalyticsId(): Promise<string | null> {
const client: MatrixClient = window.matrixclient;
if (widget) {
return getUrlParams().posthogUserId;
if (this.config.matrixBackend === "embedded") {
return this.config.hostAnalyticsId ?? null;
} else {
const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
@@ -324,7 +355,7 @@ export class PosthogAnalytics {
}
private async setAccountAnalyticsId(analyticsID: string): Promise<void> {
if (!widget) {
if (this.config.matrixBackend !== "embedded") {
const client = window.matrixclient;
// the analytics ID only needs to be set in the standalone version.
@@ -362,7 +393,7 @@ export class PosthogAnalytics {
// These properties will be subsequently passed in every event.
//
// This only needs to be done once per page lifetime. Note that getPlatformProperties
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
this.platformSuperProperties = this.getPlatformProperties();
this.registerSuperProperties({
...this.platformSuperProperties,
registrationType:
+4 -3
View File
@@ -17,7 +17,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { initClient } from "../utils/matrix";
import { type Session } from "../ClientContext";
import { Config } from "../config/Config";
import { widget } from "../widget";
import { useUrlParams } from "../UrlParams";
export const useInteractiveRegistration = (
oldClient?: MatrixClient,
@@ -32,6 +32,7 @@ export const useInteractiveRegistration = (
passwordlessUser: boolean,
) => Promise<[MatrixClient, Session]>;
} => {
const { isWidget } = useUrlParams();
const [privacyPolicyUrl, setPrivacyPolicyUrl] = useState<string | undefined>(
undefined,
);
@@ -47,7 +48,7 @@ export const useInteractiveRegistration = (
}
useEffect(() => {
if (widget) return;
if (isWidget) return;
// An empty registerRequest is used to get the privacy policy and recaptcha key.
authClient.current!.registerRequest({}).catch((error) => {
setPrivacyPolicyUrl(
@@ -55,7 +56,7 @@ export const useInteractiveRegistration = (
);
setRecaptchaKey(error.data?.params["m.login.recaptcha"]?.public_key);
});
}, []);
}, [isWidget]);
const register = useCallback(
async (
+4 -3
View File
@@ -12,7 +12,7 @@ import { useClient } from "../ClientContext";
import { useInteractiveRegistration } from "../auth/useInteractiveRegistration";
import { generateRandomName } from "../auth/generateRandomName";
import { useRecaptcha } from "../auth/useRecaptcha";
import { widget } from "../widget";
import { useUrlParams } from "../UrlParams";
interface UseRegisterPasswordlessUserType {
privacyPolicyUrl?: string;
@@ -22,6 +22,7 @@ interface UseRegisterPasswordlessUserType {
export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
const { setClient } = useClient();
const { isWidget } = useUrlParams();
const { privacyPolicyUrl, recaptchaKey, register } =
useInteractiveRegistration();
const { execute, reset, recaptchaId } = useRecaptcha(recaptchaKey);
@@ -31,7 +32,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
if (!setClient) {
throw new Error("No client context");
}
if (widget) {
if (isWidget) {
throw new Error(
"Registration was skipped: We should never try to register password-less user in embedded mode.",
);
@@ -53,7 +54,7 @@ export function useRegisterPasswordlessUser(): UseRegisterPasswordlessUserType {
throw e;
}
},
[execute, reset, register, setClient],
[execute, reset, register, setClient, isWidget],
);
return { privacyPolicyUrl, registerPasswordlessUser, recaptchaId };
+3 -1
View File
@@ -29,7 +29,9 @@ const reactionData = {
reactions$: new BehaviorSubject({}),
};
const mediaDevices = new MediaDevices(globalScope);
const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
/**
* A wrapper component that is used for:
@@ -15,6 +15,7 @@ import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
@@ -105,6 +106,7 @@ describe("createCallFooterViewModel", () => {
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.audioOptions$.value).toEqual([]);
@@ -126,6 +128,7 @@ describe("createCallFooterViewModel", () => {
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
expect(vm.audioOptions$?.value).toEqual([
+5 -2
View File
@@ -19,7 +19,7 @@ import { type Behavior, constant } from "../state/Behavior";
import type { ObservableScope } from "../state/ObservableScope";
import { type MuteStates } from "../state/MuteStates";
import { createStaticViewModel, type ViewModel } from "../state/ViewModel";
import { getUrlParams, HeaderStyle } from "../UrlParams";
import { HeaderStyle } from "../UrlParams";
import { platform } from "../Platform";
import { type FooterSnapshot } from "./CallFooter";
@@ -138,6 +138,8 @@ function buildDeviceBehaviors(
* @param mediaDevices - Available and selected input devices.
* @param reactionIdentifier - The local user's reaction identifier string, or
* undefined when reactions are not supported (hides the reaction button).
* @param options - `showControls`: whether the call controls should be shown.
* `header`: the style of header, which decides whether to show the logo.
*/
export function createCallFooterViewModel(
scope: ObservableScope,
@@ -145,8 +147,9 @@ export function createCallFooterViewModel(
muteStates: MuteStates,
mediaDevices: MediaDevices,
reactionIdentifier: string | undefined,
options: { showControls: boolean; header: HeaderStyle },
): ViewModel<FooterSnapshot> {
const { showControls, header: headerStyle } = getUrlParams();
const { showControls, header: headerStyle } = options;
const showLogo = headerStyle === HeaderStyle.Standard;
const isPip$ = scope.behavior(
@@ -14,7 +14,9 @@ import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { globalScope } from "../state/ObservableScope";
const mediaDevices = new MediaDevices(globalScope);
const mediaDevices = new MediaDevices(globalScope, {
controlledAudioDevices: false,
});
const meta = {
component: MediaMuteAndSwitchButton,
+51 -2
View File
@@ -8,8 +8,8 @@ Please see LICENSE in the repository root for full details.
import { describe, expect, it, vi, afterEach } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { validateConfig } from "./Config";
import { MatrixRTCMode } from "./ConfigOptions";
import { Config, validateConfig } from "./Config";
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
describe("validateConfig", () => {
afterEach(() => {
@@ -52,3 +52,52 @@ describe("validateConfig", () => {
expect(result.ssla).toBe("https://example.invalid/ssla");
});
});
describe("Config.initWith", () => {
// vitest.setup.ts has already called initDefault(), so every test here is
// free to re-initialize; the last call wins.
afterEach(() => {
vi.restoreAllMocks();
Config.initDefault();
});
it("makes the supplied config readable", () => {
Config.initWith({ ssla: "https://example.invalid/ssla" });
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
});
it("fills in defaults for keys the embedder did not supply", () => {
Config.initWith({ ssla: "https://example.invalid/ssla" });
expect(Config.get().media_quality).toEqual(DEFAULT_CONFIG.media_quality);
});
it("validates the supplied config just as a fetched one would be", () => {
const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
Config.initWith({
matrix_rtc_mode: "nonsense" as unknown as MatrixRTCMode,
});
expect(Config.get().matrix_rtc_mode).toBeUndefined();
expect(warnSpy).toHaveBeenCalledTimes(1);
});
it("does not share nested state with DEFAULT_CONFIG", () => {
Config.initWith({});
expect(Config.get().media_quality).not.toBe(DEFAULT_CONFIG.media_quality);
});
it("stops a later init() from fetching over the top of it", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
Config.initWith({ ssla: "https://example.invalid/ssla" });
await Config.init();
expect(fetchSpy).not.toHaveBeenCalled();
expect(Config.get().ssla).toBe("https://example.invalid/ssla");
});
it("replaces a config initialized earlier", () => {
Config.initWith({ ssla: "https://first.invalid/ssla" });
Config.initWith({ ssla: "https://second.invalid/ssla" });
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
});
});
+42 -7
View File
@@ -30,6 +30,14 @@ export class Config {
return this.internalInstance.config;
}
/**
* Initializes the config by fetching `config.json`, locating it relative to
* the current page.
*
* Does nothing if the config has already been initialized, including by
* {@link Config.initWith}, so that the regular startup path can run unchanged
* when an embedder has already supplied the config.
*/
public static async init(): Promise<void> {
if (!Config.internalInstance?.initPromise) {
const internalInstance = new Config();
@@ -50,17 +58,36 @@ export class Config {
Config.internalInstance.initPromise = downloadConfig(fetchTarget).then(
(config) => {
internalInstance.config = merge(
{},
DEFAULT_CONFIG,
validateConfig(config),
);
internalInstance.config = resolveConfig(config);
},
);
}
return Config.internalInstance.initPromise;
}
/**
* Initializes the config from an object supplied by the embedder, instead of
* fetching `config.json`.
*
* {@link Config.init} derives the location of `config.json` from
* `window.location`, which only makes sense while Element Call owns the page.
* When it is embedded in a host application the host owns the configuration
* and passes it in here.
*
* The config goes through the same validation and defaulting as a fetched
* one, so that an injected config behaves identically to a hosted one.
*
* Replaces any config initialized earlier.
*/
public static initWith(config: ConfigOptions): void {
const internalInstance = new Config();
internalInstance.config = resolveConfig(config);
// Mark initialization as already done, so that a later init() resolves
// immediately rather than fetching config.json over the top of this.
internalInstance.initPromise = Promise.resolve();
Config.internalInstance = internalInstance;
}
/**
* This is a alternative initializer that does not load anything
* from a hosted config file but instead just initializes the config using the
@@ -69,8 +96,7 @@ export class Config {
* It is supposed to only be used in tests. (It is executed in `vite.setup.js`)
*/
public static initDefault(): void {
Config.internalInstance = new Config();
Config.internalInstance.config = { ...DEFAULT_CONFIG };
Config.initWith({});
}
// Convenience accessors
@@ -94,6 +120,15 @@ export class Config {
private initPromise?: Promise<void>;
}
/**
* Applies validation and the built-in defaults to a config, however it was
* obtained. Deep-merges onto a fresh object so that the result never shares
* nested state with {@link DEFAULT_CONFIG}.
*/
function resolveConfig(config: ConfigOptions): ResolvedConfigOptions {
return merge({}, DEFAULT_CONFIG, validateConfig(config));
}
export function validateConfig(config: ConfigOptions): ConfigOptions {
const mode = config.matrix_rtc_mode;
if (mode !== undefined && !VALID_MATRIX_RTC_MODES.has(mode)) {
+36
View File
@@ -0,0 +1,36 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test } from "vitest";
import { getKeyForRoom, saveKeyForRoom } from "./sharedKeyManagement";
const roomId = "!room:example.org";
describe("getKeyForRoom", () => {
afterEach(() => {
window.location.hash = "#";
localStorage.clear();
});
test("prefers a key given in the parameters over the stored one", () => {
saveKeyForRoom(roomId, "stored");
window.location.hash = `#?roomId=${encodeURIComponent(roomId)}&password=from-the-link`;
expect(getKeyForRoom(roomId)).toBe("from-the-link");
});
test("falls back to the stored key", () => {
saveKeyForRoom(roomId, "stored");
expect(getKeyForRoom(roomId)).toBe("stored");
});
test("has no key to offer for a room it has never seen", () => {
expect(getKeyForRoom(roomId)).toBeNull();
});
});
+23 -6
View File
@@ -12,7 +12,7 @@ import {
setLocalStorageItemReactive,
useLocalStorage,
} from "../useLocalStorage";
import { getUrlParams } from "../UrlParams";
import { getUrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "./e2eeType";
import { useClient } from "../ClientContext";
@@ -57,19 +57,31 @@ const useRoomSharedKey = (
return [setInitialValue ?? roomSharedKey, setRoomSharedKey];
};
export function getKeyForRoom(roomId: string): string | null {
const { roomId: urlRoomId, password } = getUrlParams();
if (roomId !== urlRoomId)
/**
* The shared key for a room, preferring one supplied in the parameters Element
* Call was started with over whatever is in local storage.
*/
function keyForRoom(
roomId: string,
paramsRoomId: string | null,
password: string | null,
): string | null {
if (roomId !== paramsRoomId)
logger.warn(
"requested key for a roomId which is not the current call room id (from the URL)",
roomId,
urlRoomId,
paramsRoomId,
);
return (
password ?? localStorage.getItem(getRoomSharedKeyLocalStorageKey(roomId))
);
}
export function getKeyForRoom(roomId: string): string | null {
const { roomId: paramsRoomId, password } = getUrlParams();
return keyForRoom(roomId, paramsRoomId, password);
}
export type Unencrypted = { kind: E2eeType.NONE };
export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
@@ -77,10 +89,15 @@ export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
const { roomId: paramsRoomId, password } = useUrlParams();
const [storedPassword] = useRoomSharedKey(
// TODO: this passes an already-prefixed key where a room ID is expected, so
// the local storage key ends up prefixed twice and never matches what
// saveKeyForRoom writes. Preserved as-is here to keep this commit a pure
// refactor; the reactive read is effectively dead until it is fixed.
getRoomSharedKeyLocalStorageKey(roomId),
getKeyForRoom(roomId) ?? undefined,
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
);
const room = client?.getRoom(roomId);
+1 -2
View File
@@ -13,7 +13,6 @@ import { ErrorPage, LoadingPage } from "../FullScreenView";
import { UnauthenticatedView } from "./UnauthenticatedView";
import { RegisteredView } from "./RegisteredView";
import { usePageTitle } from "../usePageTitle";
import { widget } from "../widget.ts";
export const HomePage: FC = () => {
const { t } = useTranslation();
@@ -24,7 +23,7 @@ export const HomePage: FC = () => {
if (!clientState) {
return <LoadingPage />;
} else if (clientState.state === "error") {
return <ErrorPage widget={widget} error={clientState.error} />;
return <ErrorPage error={clientState.error} />;
} else {
return clientState.authenticated ? (
<RegisteredView client={clientState.authenticated.client} />
+45 -6
View File
@@ -6,12 +6,11 @@ Please see LICENSE in the repository root for full details.
*/
import React from "react";
import i18n, {
import {
type BackendModule,
type ReadCallback,
type ResourceKey,
} from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import * as Sentry from "@sentry/react";
import { logger } from "matrix-js-sdk/lib/logger";
@@ -33,8 +32,13 @@ import { Config } from "./config/Config";
import { seedSettingsFromConfig } from "./settings/settings";
import { platform } from "./Platform";
import { isFailure } from "./utils/fetch";
import { initializeWidget } from "./widget";
import { initializeWidget, type WidgetHelpers } from "./widget";
import { enableExtendedLivekitLogs } from "./settings/settings.ts";
import {
type AnalyticsConfig,
PosthogAnalytics,
} from "./analytics/PosthogAnalytics.ts";
import { i18n } from "./utils/i18n.ts";
// This generates a map of locale names to their URL (based on import.meta.url), which looks like this:
// {
@@ -98,6 +102,36 @@ const Backend = {
},
} satisfies BackendModule;
/**
* Where analytics reporting is configured from.
*
* Note the two halves are decided differently, and deliberately so. *Where the
* PostHog credentials come from* depends on the package: an embedder passes
* them in through the URL because it is responsible for its own users'
* telemetry, whereas a standalone deployment is configured by whoever operates
* it. *Who owns the user's analytics identity*, on the other hand, depends on
* how Element Call is actually running right now — the full package can be used
* as a widget too.
*/
// Exported for testing
export function analyticsConfigFromEnvironment(): AnalyticsConfig {
const { posthogApiKey, posthogApiHost, posthogUserId, isWidget } =
getUrlParams();
return {
matrixBackend: isWidget ? "embedded" : "jssdk",
hostAnalyticsId: posthogUserId,
...(import.meta.env.VITE_PACKAGE === "embedded"
? {
apiKey: posthogApiKey ?? undefined,
apiHost: posthogApiHost ?? undefined,
}
: {
apiKey: Config.get().posthog?.api_key,
apiHost: Config.get().posthog?.api_host,
}),
};
}
enum LoadState {
None,
Loading,
@@ -121,8 +155,8 @@ export class Initializer {
return !!Initializer.internalInstance?.isInitialized;
}
public static async initBeforeReact(): Promise<void> {
initializeWidget();
public static async initBeforeReact(): Promise<WidgetHelpers | null> {
const widget = initializeWidget();
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillSegmenter()) {
@@ -148,10 +182,12 @@ export class Initializer {
document.documentElement.lang = lng;
});
// Note: deliberately no `.use(initReactI18next)` — that would register this
// instance as react-i18next's global default, which is the very global we
// are avoiding. Components receive it through `<I18nextProvider>` instead.
await i18n
.use(Backend)
.use(languageDetector)
.use(initReactI18next)
.init({
fallbackLng: "en",
defaultNS: "app",
@@ -207,6 +243,8 @@ export class Initializer {
});
window.setLKLogLevel = setLKLogLevel;
return widget;
}
public static init(): Promise<void> | null {
@@ -239,6 +277,7 @@ export class Initializer {
Config.init().then(
() => {
seedSettingsFromConfig(Config.get().media_quality);
PosthogAnalytics.configure(analyticsConfigFromEnvironment());
this.loadStates.config = LoadState.Loaded;
this.initStep(resolve);
},
+12 -2
View File
@@ -21,6 +21,7 @@ import { init as initRageshake } from "./settings/rageshake";
import { Initializer } from "./initializer";
import { AppViewModel } from "./state/AppViewModel";
import { globalScope } from "./state/ObservableScope";
import { getUrlParams } from "./UrlParams";
initRageshake().catch((e) => {
logger.error("Failed to initialize rageshake", e);
@@ -48,10 +49,19 @@ if (fatalError !== null) {
}
Initializer.initBeforeReact()
.then(() => {
.then((widget) => {
const { controlledAudioDevices, callIntent } = getUrlParams();
root.render(
<StrictMode>
<App vm={new AppViewModel(globalScope)} />
<App
vm={
new AppViewModel(globalScope, {
controlledAudioDevices,
callIntent,
})
}
widget={widget}
/>
</StrictMode>,
);
})
+20 -38
View File
@@ -36,7 +36,11 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { mockConfig } from "../utils/test.ts";
import { ElementWidgetActions, type WidgetHelpers } from "../widget.ts";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../HostBridge.ts";
test.each([
{
@@ -79,7 +83,6 @@ test.each([
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -108,7 +111,6 @@ test("should render the error page with link back to home", async () => {
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -154,10 +156,7 @@ test("ConnectionLostError: Action handling should reset error state", async () =
return (
<BrowserRouter>
<GroupCallErrorBoundary
recoveryActionHandler={reconnectCallback}
widget={null}
>
<GroupCallErrorBoundary recoveryActionHandler={reconnectCallback}>
<TestComponent fail={failState} />
</GroupCallErrorBoundary>
</BrowserRouter>
@@ -199,7 +198,6 @@ describe("Rageshake button", () => {
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -224,29 +222,27 @@ describe("Rageshake button", () => {
});
});
test("should have a close button in widget mode", async () => {
test("should have a close button when the host can dismiss us", async () => {
const error = new MatrixRTCTransportMissingError("example.com");
const TestComponent = (): ReactNode => {
throw error;
};
const mockWidget = {
api: {
transport: { send: vi.fn().mockResolvedValue(undefined), stop: vi.fn() },
},
} as unknown as WidgetHelpers;
const close = vi.fn().mockResolvedValue(undefined);
const hostBridge: HostBridge = { ...nullHostBridge, close };
const user = userEvent.setup();
const onErrorMock = vi.fn();
const { asFragment } = render(
<BrowserRouter>
<GroupCallErrorBoundary
widget={mockWidget}
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
<HostBridgeProvider value={hostBridge}>
<GroupCallErrorBoundary
onError={onErrorMock}
recoveryActionHandler={vi.fn()}
>
<TestComponent />
</GroupCallErrorBoundary>
</HostBridgeProvider>
</BrowserRouter>,
);
@@ -258,11 +254,7 @@ test("should have a close button in widget mode", async () => {
await user.click(screen.getByRole("button", { name: "Close" }));
expect(mockWidget.api.transport.send).toHaveBeenCalledWith(
ElementWidgetActions.Close,
expect.anything(),
);
expect(mockWidget.api.transport.stop).toHaveBeenCalled();
expect(close).toHaveBeenCalled();
});
test("should show technical details when error has a matrixError cause", async () => {
@@ -282,11 +274,7 @@ test("should show technical details when error has a matrixError cause", async (
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
@@ -315,11 +303,7 @@ test("should not show technical details when error has no matrix error cause", a
render(
<BrowserRouter>
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<GroupCallErrorBoundary onError={vi.fn()} recoveryActionHandler={vi.fn()}>
<TestComponent />
</GroupCallErrorBoundary>
</BrowserRouter>,
@@ -376,7 +360,6 @@ describe("LiveKit ConnectionError variants", () => {
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
@@ -406,7 +389,6 @@ describe("LiveKit ConnectionError variants", () => {
<GroupCallErrorBoundary
onError={vi.fn()}
recoveryActionHandler={vi.fn()}
widget={null}
>
<TestComponent />
</GroupCallErrorBoundary>
+1 -8
View File
@@ -34,7 +34,6 @@ import {
} from "../utils/errors.ts";
import { FullScreenView } from "../FullScreenView.tsx";
import { ErrorView } from "../ErrorView.tsx";
import { type WidgetHelpers } from "../widget.ts";
import styles from "../ErrorView.module.css";
export type CallErrorRecoveryAction = "reconnect"; // | "retry" ;
@@ -47,13 +46,11 @@ interface ErrorPageProps {
error: ElementCallError;
recoveryActionHandler: RecoveryActionHandler;
resetError: () => void;
widget: WidgetHelpers | null;
}
const ErrorPage: FC<ErrorPageProps> = ({
error,
recoveryActionHandler,
widget,
}: ErrorPageProps): ReactElement => {
const { t } = useTranslation();
logger.error("Error boundary caught:", error);
@@ -89,7 +86,6 @@ const ErrorPage: FC<ErrorPageProps> = ({
Icon={icon}
title={error.localisedTitle}
rageshake={error.code == ErrorCode.UNKNOWN_ERROR}
widget={widget}
>
<p>
{error.localisedMessageKey ? (
@@ -148,14 +144,12 @@ interface BoundaryProps {
children: ReactNode | (() => ReactNode);
recoveryActionHandler: RecoveryActionHandler;
onError?: (error: unknown) => void;
widget: WidgetHelpers | null;
}
export const GroupCallErrorBoundary = ({
recoveryActionHandler,
onError,
children,
widget,
}: BoundaryProps): ReactElement => {
const fallbackRenderer: FallbackRender = useCallback(
({ error, resetError }): ReactElement => {
@@ -165,7 +159,6 @@ export const GroupCallErrorBoundary = ({
: new UnknownCallError(error instanceof Error ? error : new Error());
return (
<ErrorPage
widget={widget ?? null}
error={callError}
resetError={resetError}
recoveryActionHandler={async (action: CallErrorRecoveryAction) => {
@@ -175,7 +168,7 @@ export const GroupCallErrorBoundary = ({
/>
);
},
[recoveryActionHandler, widget],
[recoveryActionHandler],
);
return (
+49 -83
View File
@@ -36,7 +36,6 @@ import userEvent, {
import { type RelationsContainer } from "matrix-js-sdk/lib/models/relations-container";
import { useState } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
import { type ITransport } from "matrix-widget-api";
import { prefetchSounds } from "../soundUtils";
import { useAudioContext } from "../useAudioContext";
@@ -52,8 +51,11 @@ import {
} from "../utils/test";
import { GroupCallView } from "./GroupCallView";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary";
import { ElementWidgetActions, type WidgetHelpers } from "../widget";
import { LazyEventEmitter } from "../LazyEventEmitter";
import {
type HostBridge,
HostBridgeProvider,
nullHostBridge,
} from "../HostBridge";
import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -134,7 +136,7 @@ beforeEach(() => {
});
function createGroupCallView(
widget: WidgetHelpers | null,
hostBridge: HostBridge,
joined = true,
options: {
withErrorBoundary?: boolean;
@@ -184,7 +186,6 @@ function createGroupCallView(
skipLobby={false}
rtcSession={rtcSession.asMockedSession()}
muteStates={muteState}
widget={widget}
// TODO-MULTI-SFU: Make joined and setJoined work
joined={true}
setJoined={function (value: boolean): void {}}
@@ -192,22 +193,21 @@ function createGroupCallView(
);
const { getByText } = render(
<BrowserRouter>
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary
recoveryActionHandler={vi.fn()}
widget={null}
>
{groupCallView}
</GroupCallErrorBoundary>
) : (
groupCallView
)}
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
<HostBridgeProvider value={hostBridge}>
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{groupCallView}
</GroupCallErrorBoundary>
) : (
groupCallView
)}
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
</HostBridgeProvider>
</BrowserRouter>,
);
return {
@@ -218,7 +218,7 @@ function createGroupCallView(
test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async () => {
const user = userEvent.setup();
const { getByText, rtcSession } = createGroupCallView(null);
const { getByText, rtcSession } = createGroupCallView(nullHostBridge);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
expect(playSound).toHaveBeenCalledWith("left");
@@ -235,12 +235,7 @@ test.skip("GroupCallView plays a leave sound asynchronously in SPA mode", async
test.skip("GroupCallView plays a leave sound synchronously in widget mode", async () => {
const user = userEvent.setup();
const widget = {
api: {
setAlwaysOnScreen: async () => Promise.resolve(true),
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
};
const hostBridge: HostBridge = { ...nullHostBridge, close: vi.fn() };
let resolvePlaySound: () => void;
playSound = vi
.fn()
@@ -253,9 +248,7 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
soundDuration: {},
});
const { getByText, rtcSession } = createGroupCallView(
widget as WidgetHelpers,
);
const { getByText, rtcSession } = createGroupCallView(hostBridge);
const leaveButton = getByText("Leave");
await user.click(leaveButton);
await flushPromises();
@@ -272,28 +265,13 @@ test.skip("GroupCallView plays a leave sound synchronously in widget mode", asyn
expect(leaveRTCSession).toHaveBeenCalledOnce();
});
test("Should close widget when all other left and play a sound", async () => {
test("Should ask the host to close when all other left and play a sound", async () => {
const user = userEvent.setup();
let widgetClosedCalled = false;
const { promise: widgetClosedPromise, resolve: widgetClosedResolver } =
Promise.withResolvers<void>();
const widgetSendMock = vi.fn().mockImplementation((action: string) => {
if (action === ElementWidgetActions.Close) {
widgetClosedCalled = true;
widgetClosedResolver();
}
});
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
const close = vi.fn().mockResolvedValue(undefined);
const hostBridge: HostBridge = {
...nullHostBridge,
setAlwaysOnScreen: vi.fn().mockResolvedValue(undefined),
close,
};
const resolvePlaySound = Promise.withResolvers<void>();
playSound = vi.fn().mockReturnValue(resolvePlaySound.promise);
@@ -303,50 +281,38 @@ test("Should close widget when all other left and play a sound", async () => {
soundDuration: {},
});
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const { getByText } = createGroupCallView(hostBridge);
const leaveButton = getByText("SimulateOtherLeft");
await user.click(leaveButton);
await flushPromises();
expect(widgetClosedCalled).toBeFalsy();
expect(close).not.toHaveBeenCalled();
resolvePlaySound.resolve();
expect(playSound).toHaveBeenCalledWith("left", 0);
await widgetClosedPromise;
await flushPromises();
expect(widgetClosedCalled).toBeTruthy();
expect(widgetStopMock).toHaveBeenCalledOnce();
await waitFor(() => expect(close).toHaveBeenCalledOnce());
}, 80000);
test("Should not close widget when auto leave due to error", async () => {
test("Should not ask the host to close when auto leave due to error", async () => {
const user = userEvent.setup();
const widgetStopMock = vi.fn().mockResolvedValue(undefined);
const widgetSendMock = vi.fn().mockResolvedValue(undefined);
const widget = {
api: {
setAlwaysOnScreen: vi.fn().mockResolvedValue(true),
transport: {
send: widgetSendMock,
reply: vi.fn().mockResolvedValue(undefined),
stop: widgetStopMock,
} as unknown as ITransport,
} as Partial<WidgetHelpers["api"]>,
lazyActions: new LazyEventEmitter(),
const close = vi.fn().mockResolvedValue(undefined);
const setAlwaysOnScreen = vi.fn().mockResolvedValue(undefined);
const hostBridge: HostBridge = {
...nullHostBridge,
setAlwaysOnScreen,
close,
};
const alwaysOnScreenSpy = vi.spyOn(widget.api, "setAlwaysOnScreen");
const { getByText } = createGroupCallView(widget as WidgetHelpers);
const { getByText } = createGroupCallView(hostBridge);
const leaveButton = getByText("SimulateErrorLeft");
await user.click(leaveButton);
await flushPromises();
// When onLeft is called, we first set always on screen to false
await waitFor(() => expect(alwaysOnScreenSpy).toHaveBeenCalledWith(false));
await waitFor(() => expect(setAlwaysOnScreen).toHaveBeenCalledWith(false));
await flushPromises();
// But then we do not close the widget automatically
expect(widgetStopMock).not.toHaveBeenCalledOnce();
expect(widgetSendMock).not.toHaveBeenCalledOnce();
// But then we do not ask to be closed automatically
expect(close).not.toHaveBeenCalled();
});
test.skip("GroupCallView leaves the session when an error occurs", async () => {
@@ -360,7 +326,7 @@ test.skip("GroupCallView leaves the session when an error occurs", async () => {
);
});
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null);
const { rtcSession } = createGroupCallView(nullHostBridge);
await user.click(screen.getByRole("button", { name: "Panic!" }));
screen.getByText("Something went wrong");
expect(leaveRTCSession).toHaveBeenCalledWith(
@@ -377,7 +343,7 @@ test.skip("GroupCallView shows errors that occur during joining", async () => {
onTestFinished(() => {
enterRTCSession.mockReset();
});
createGroupCallView(null, false);
createGroupCallView(nullHostBridge, false);
await user.click(screen.getByRole("button", { name: "Join call" }));
screen.getByText("Call is not supported");
});
@@ -396,7 +362,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
{ cause: stickyError },
);
const { rtcSession } = createGroupCallView(null, true, {
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
withErrorBoundary: true,
});
@@ -408,7 +374,7 @@ test("translates wrapped UnsupportedStickyEventsEndpointError to the StickyEvent
});
test("falls back to ConnectionLostError for unrecognised membership manager errors", async () => {
const { rtcSession } = createGroupCallView(null, true, {
const { rtcSession } = createGroupCallView(nullHostBridge, true, {
withErrorBoundary: true,
});
@@ -424,7 +390,7 @@ test("falls back to ConnectionLostError for unrecognised membership manager erro
test("user can reconnect after a membership manager error", async () => {
const user = userEvent.setup();
const { rtcSession } = createGroupCallView(null, true);
const { rtcSession } = createGroupCallView(nullHostBridge, true);
await act(() =>
rtcSession.emit(MatrixRTCSessionEvent.MembershipManagerError, undefined),
);
+63 -73
View File
@@ -30,12 +30,7 @@ import {
} from "matrix-js-sdk/lib/matrixrtc";
import { useNavigate } from "react-router-dom";
import type { IWidgetApiRequest } from "matrix-widget-api";
import {
ElementWidgetActions,
type JoinCallData,
type WidgetHelpers,
} from "../widget";
import { type JoinCallData } from "../widget";
import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
@@ -54,12 +49,7 @@ import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import {
getUrlParams,
HeaderStyle,
type UrlParams,
useUrlParams,
} from "../UrlParams";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
import { useAudioContext } from "../useAudioContext";
import {
@@ -80,6 +70,8 @@ import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
import { useRootElement } from "../RootElementContext.ts";
import { useHostBridge } from "../HostBridge.ts";
/**
* If there already are this many participants in the call, we automatically mute
@@ -103,7 +95,6 @@ interface Props {
joined: boolean;
setJoined: (value: boolean) => void;
muteStates: MuteStates;
widget: WidgetHelpers | null;
}
export const GroupCallView: FC<Props> = ({
@@ -116,13 +107,21 @@ export const GroupCallView: FC<Props> = ({
joined,
setJoined,
muteStates,
widget,
}) => {
// Used to thread through any errors that occur outside the error boundary
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const rootElement = useRootElement();
const hostBridge = useHostBridge();
// A host that can close us is a host that decides when we stop existing, so
// we neither show our own post-call screens nor assume we have time to
// finish what we are doing.
// TODO: this reads a capability as a proxy for who owns our lifetime. Worth
// finding a more direct way to express it — see the guidance in UrlParams.ts
// on naming behaviours rather than situations.
const hostControlsLifetime = hostBridge.close !== undefined;
const muteAllAudio = useBehavior(muteAllAudio$);
const leaveSoundContext = useLatest(
@@ -150,11 +149,11 @@ export const GroupCallView: FC<Props> = ({
// viewport sizes smaller than 122px width. (It is actually this exact number: 122px
// tested on different devices...)
useEffect(() => {
document.body.classList.add("no-scroll-body");
rootElement.classList.add("no-scroll-body");
return (): void => {
document.body.classList.remove("no-scroll-body");
rootElement.classList.remove("no-scroll-body");
};
}, []);
}, [rootElement]);
useEffect(() => {
window.rtcSession = rtcSession;
@@ -297,28 +296,26 @@ export const GroupCallView: FC<Props> = ({
};
if (skipLobby) {
if (widget && preload) {
// `preload` is only ever set when we have a host to be preloaded by.
if (preload) {
// In preload mode without lobby we wait for a join action before entering
const onJoin = (ev: CustomEvent<IWidgetApiRequest>): void => {
const subscription = hostBridge.join$.subscribe(({ data, reply }) => {
(async (): Promise<void> => {
await defaultDeviceSetup(ev.detail.data as unknown as JoinCallData);
await defaultDeviceSetup(data);
setJoined(true);
widget.api.transport.reply(ev.detail, {});
reply();
})().catch((e) => {
logger.error("Error joining RTC session on preload", e);
});
};
widget.lazyActions.on(ElementWidgetActions.JoinCall, onJoin);
return (): void => {
widget.lazyActions.off(ElementWidgetActions.JoinCall, onJoin);
};
});
return (): void => subscription.unsubscribe();
} else {
// No lobby and no preload: we enter the rtc session right away
setJoined(true);
}
}
}, [
widget,
hostBridge,
rtcSession,
preload,
skipLobby,
@@ -344,7 +341,7 @@ export const GroupCallView: FC<Props> = ({
// When "allOthersLeft", the leaveSoundEffect$ in CallEventAudioRenderer
// already plays the "left" sound when the remote participant's media
// disappears. We play it here silenced (volumeOverwrite = 0) so we have the right duration in the audioPromise.
// (used to destory the widget)
// (which is what delays asking the host to close us)
audioPromise = leaveSoundContext.current?.playSound("left", 0);
break;
case "timeout":
@@ -359,12 +356,12 @@ export const GroupCallView: FC<Props> = ({
setLeft(true);
// We need to wait until the callEnded event is tracked on PostHog,
// otherwise the iframe may get killed first.
// otherwise we may be torn down first.
const posthogRequest = new Promise((resolve) => {
// To increase the likelihood of the PostHog event being sent out in
// widget mode before the iframe is killed, we ask it to skip the
// usual queuing/batching of requests.
const sendInstantly = widget !== null;
// To increase the likelihood of the PostHog event being sent out
// before the host disposes of us, we ask it to skip the usual
// queuing/batching of requests.
const sendInstantly = hostControlsLifetime;
PosthogAnalytics.instance.eventCallEnded.track(
room.roomId,
rtcSession.memberships.length,
@@ -372,8 +369,8 @@ export const GroupCallView: FC<Props> = ({
rtcSession,
);
// Unfortunately the PostHog library provides no way to await the
// tracking of an event, but we don't really want it to hold up the
// closing of the widget that long anyway, so giving it 10 ms will do.
// tracking of an event, but we don't really want it to hold up our
// disposal that long anyway, so giving it 10 ms will do.
window.setTimeout(resolve, 10);
});
@@ -392,25 +389,19 @@ export const GroupCallView: FC<Props> = ({
)
void navigate("/");
if (widget) {
// After this point the iframe could die at any moment!
// After this point the host could dispose of us at any moment!
try {
await hostBridge.setAlwaysOnScreen(false);
} catch (e) {
logger.error("Failed to set `alwaysOnScreen` to false", e);
}
// On a normal user hangup we can shut down and ask to be closed. But
// if an error occurs we should stay open until the user reads it.
if (reason != "error" && !returnToLobby) {
try {
await widget.api.setAlwaysOnScreen(false);
await hostBridge.close?.();
} catch (e) {
logger.error(
"Failed to set call widget `alwaysOnScreen` to false",
e,
);
}
// On a normal user hangup we can shut down and close the widget. But if an
// error occurs we should keep the widget open until the user reads it.
if (reason != "error" && !getUrlParams().returnToLobby) {
try {
await widget.api.transport.send(ElementWidgetActions.Close, {});
} catch (e) {
logger.error("Failed to send close action", e);
}
widget.api.transport.stop();
logger.error("Failed to ask the host to close Element Call", e);
}
}
});
@@ -418,22 +409,24 @@ export const GroupCallView: FC<Props> = ({
[
setJoined,
leaveSoundContext,
widget,
hostBridge,
hostControlsLifetime,
room.roomId,
rtcSession,
isPasswordlessUser,
confineToRoom,
returnToLobby,
navigate,
],
);
useEffect(() => {
if (widget && joined)
// set widget to sticky once joined.
widget.api.setAlwaysOnScreen(true).catch((e) => {
if (joined)
// ask to be kept on screen once joined.
hostBridge.setAlwaysOnScreen(true).catch((e) => {
logger.error("Error calling setAlwaysOnScreen(true)", e);
});
}, [widget, joined, rtcSession]);
}, [hostBridge, joined, rtcSession]);
const joinRule = useJoinRule(room);
@@ -503,19 +496,16 @@ export const GroupCallView: FC<Props> = ({
/>
</>
);
} else if (left && widget === null) {
// Left in SPA mode:
} else if (left && !hostControlsLifetime) {
// Left, and it is up to us what to show next:
// The call ended view is shown for two reasons: prompting guests to create
// an account, and prompting users that have opted into analytics to provide
// feedback. We don't show a feedback prompt to widget users however (at
// least for now), because we don't yet have designs that would allow widget
// users to dismiss the feedback prompt and close the call window without
// submitting anything.
if (
isPasswordlessUser ||
(PosthogAnalytics.instance.isEnabled() && widget === null)
) {
// feedback. We don't show a feedback prompt when a host owns our lifetime
// however (at least for now), because we don't yet have designs that would
// allow those users to dismiss the feedback prompt and close the call
// window without submitting anything.
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
body = (
<CallEndedView
endedCallId={rtcSession.room.roomId}
@@ -531,8 +521,8 @@ export const GroupCallView: FC<Props> = ({
// LobbyView again which would open capture devices again.
body = null;
}
} else if (left && widget !== null) {
// Left in widget mode:
} else if (left && hostControlsLifetime) {
// Left, and the host decides what happens next:
body = returnToLobby ? lobbyView : null;
} else if (preload || skipLobby) {
// The RTC session is not joined to yet (`isJoined`), but enterRTCSessionOrError should have been called.
@@ -543,7 +533,6 @@ export const GroupCallView: FC<Props> = ({
return (
<GroupCallErrorBoundary
widget={widget}
recoveryActionHandler={async (action) => {
setExternalError(null);
if (action == "reconnect") {
@@ -555,9 +544,10 @@ export const GroupCallView: FC<Props> = ({
}}
onError={(_error) => {
if (rtcSession.isJoined()) onLeft("error");
// If there is an error we need to be able to close the widget. This is done in `onLeft` as well
// We need it here explicitly in case rtcSession.isJoined is false.
void widget?.api.setAlwaysOnScreen(false);
// If there is an error we need to be dismissible again. This is done in
// `onLeft` as well; we need it here explicitly in case
// rtcSession.isJoined is false.
void hostBridge.setAlwaysOnScreen(false);
}}
>
{body}
+14 -21
View File
@@ -28,7 +28,7 @@ import { useTranslation } from "react-i18next";
import { Header, LeftNav, RightNav, RoomHeaderInfo } from "../Header";
import { HeaderStyle, useUrlParams } from "../UrlParams";
import { useCallViewKeyboardShortcuts } from "../useCallViewKeyboardShortcuts";
import { widget } from "../widget";
import { useHostBridge } from "../HostBridge.ts";
import styles from "./InCallView.module.css";
import { GridTile } from "../tile/GridTile";
import { SettingsModal, defaultSettingsTab } from "../settings/SettingsModal";
@@ -41,6 +41,7 @@ import { type MatrixInfo } from "./VideoPreview";
import { InviteButton } from "../button/InviteButton";
import {
type CallViewModel,
callViewModelOptionsFromParams,
createCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
@@ -116,6 +117,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
useState<ViewModel<DeveloperSettingsSnapshot> | null>(null);
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const mediaDevices = useMediaDevices();
const trackProcessorState$ = useTrackProcessorObservable$();
useEffect(() => {
@@ -132,7 +134,9 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
mediaDevices,
props.muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
matrixRTCMode$: matrixRTCModeSetting.value$,
@@ -157,6 +161,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.e2eeSystem,
props.onLeft,
urlParams,
hostBridge,
mediaDevices,
trackProcessorState$,
props.client,
@@ -172,6 +177,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
props.muteStates,
mediaDevices,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
{ showControls: urlParams.showControls, header: urlParams.header },
);
setFooterVm(footerVm);
setDeveloperSettingsVm(createDeveloperSettingsTabViewModel(scope, vm));
@@ -232,6 +238,7 @@ export const InCallView: FC<InCallViewProps> = ({
}) => {
const logger = rootLogger.getChild("[InCallView]");
const { t } = useTranslation();
const hostBridge = useHostBridge();
const { sendReaction, toggleRaisedHand } = useReactionsSender();
useWakeLock();
@@ -270,7 +277,6 @@ export const InCallView: FC<InCallViewProps> = ({
const audioParticipants = useBehavior(vm.livekitRoomItems$);
const participantCount = useBehavior(vm.participantCount$);
const reconnecting = useBehavior(vm.reconnecting$);
const screenShareError = useBehavior(vm.screenShareError$);
const layout = useBehavior(vm.layout$);
const edgeToEdge = useBehavior(vm.edgeToEdge$);
const overflowing = useBehavior(vm.overflowing$);
@@ -316,14 +322,17 @@ export const InCallView: FC<InCallViewProps> = ({
const openProfile = useMemo(
() =>
// Profile settings are unavailable in widget mode
widget === null
// A host that can dismiss us is a host that owns the user's account, so
// their profile is not ours to edit.
// TODO: another use of the close capability as a proxy — see the note in
// GroupCallView.
hostBridge.close === undefined
? (): void => {
setSettingsTab("profile");
setSettingsOpen(true);
}
: null,
[setSettingsTab, setSettingsOpen],
[setSettingsTab, setSettingsOpen, hostBridge],
);
const [headerRef, headerBounds] = useMeasure();
@@ -400,21 +409,6 @@ export const InCallView: FC<InCallViewProps> = ({
);
}
const onDismissScreenShareToast = useCallback(
() => vm.dismissScreenShareError(),
[vm],
);
const screenShareToast = (
<Toast
onDismiss={onDismissScreenShareToast}
open={screenShareError !== null}
autoDismiss={5000}
modal={false}
>
{t("error.screen_share_failed")}
</Toast>
);
// The reconnecting toast cannot be dismissed
const onDismissReconnectingToast = useCallback(() => {}, []);
// We need to use a non-modal toast to avoid trapping focus within the toast.
@@ -650,7 +644,6 @@ export const InCallView: FC<InCallViewProps> = ({
<ReactionsAudioRenderer vm={vm} muted={muteAllAudio} />
<RingingAudioRenderer vm={ringingVm} muted={muteAllAudio} />
{reconnectingToast}
{screenShareToast}
{earpieceOverlay}
<ReactionsOverlay vm={vm} />
{footer}
+9 -9
View File
@@ -29,7 +29,7 @@ import { GroupCallView } from "./GroupCallView";
import { useRoomIdentifier, useUrlParams } from "../UrlParams";
import { useRegisterPasswordlessUser } from "../auth/useRegisterPasswordlessUser";
import { HomePage } from "../home/HomePage";
import { widget } from "../widget";
import { useHostBridge } from "../HostBridge.ts";
import { CallTerminatedMessage, useLoadGroupCall } from "./useLoadGroupCall";
import { LobbyView } from "./LobbyView";
import { E2eeType } from "../e2ee/e2eeType";
@@ -44,6 +44,7 @@ import { calculateInitialMuteState } from "../state/initialMuteState.ts";
export const RoomPage: FC = (): ReactNode => {
const urlParams = useUrlParams();
const hostBridge = useHostBridge();
const { confineToRoom, preload, header, displayName, skipLobby } = urlParams;
const { t } = useTranslation();
const { roomAlias, roomId, viaServers } = useRoomIdentifier();
@@ -75,17 +76,18 @@ export const RoomPage: FC = (): ReactNode => {
calculateInitialMuteState(
urlParams.skipLobby,
urlParams.callIntent,
widget !== null,
urlParams.isWidget,
),
hostBridge,
),
);
return (): void => scope.end();
}, [devices, urlParams]);
}, [devices, urlParams, hostBridge]);
useEffect(() => {
// If we've finished loading, are not already authed and we've been given a display name as
// a URL param, automatically register a passwordless user
if (!loading && !authenticated && displayName && !widget) {
if (!loading && !authenticated && displayName && !urlParams.isWidget) {
setIsRegistering(true);
registerPasswordlessUser(displayName)
.catch((e) => {
@@ -99,6 +101,7 @@ export const RoomPage: FC = (): ReactNode => {
loading,
authenticated,
displayName,
urlParams.isWidget,
setIsRegistering,
registerPasswordlessUser,
]);
@@ -123,7 +126,6 @@ export const RoomPage: FC = (): ReactNode => {
return (
muteStates && (
<GroupCallView
widget={widget}
client={client!}
rtcSession={groupCallState.rtcSession}
joined={joined}
@@ -198,7 +200,6 @@ export const RoomPage: FC = (): ReactNode => {
<ErrorView
Icon={UnknownSolidIcon}
title={t("error.call_not_found")}
widget={widget}
>
<Trans i18nKey="error.call_not_found_description">
<p>
@@ -216,7 +217,6 @@ export const RoomPage: FC = (): ReactNode => {
<ErrorView
Icon={groupCallState.error.icon}
title={groupCallState.error.message}
widget={widget}
>
<p>{groupCallState.error.messageBody}</p>
{groupCallState.error.reason && (
@@ -230,7 +230,7 @@ export const RoomPage: FC = (): ReactNode => {
</FullScreenView>
);
} else {
return <ErrorPage widget={widget} error={groupCallState.error} />;
return <ErrorPage error={groupCallState.error} />;
}
default:
return <> </>;
@@ -238,7 +238,7 @@ export const RoomPage: FC = (): ReactNode => {
};
if (loading || isRegistering) return <LoadingPage />;
if (error) return <ErrorPage widget={widget} error={error} />;
if (error) return <ErrorPage error={error} />;
if (!client) return <RoomAuthView />;
// TODO: This doesn't belong here, the app routes need to be reworked
if (!roomIdOrAlias) return <HomePage />;
@@ -1100,7 +1100,7 @@ exports[`LiveKit ConnectionError variants > should link to troubleshoot guide wh
</DocumentFragment>
`;
exports[`should have a close button in widget mode 1`] = `
exports[`should have a close button when the host can dismiss us 1`] = `
<DocumentFragment>
<div
class="_page_4be5c0"
+10 -10
View File
@@ -171,7 +171,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="_settingsLogoContainer_20b7b4"
>
<button
aria-labelledby="_r_b_"
aria-labelledby="_r_8_"
class="_icon-button_1215g_8 _settingsOnlyShowWide_20b7b4"
data-kind="secondary"
data-testid="settings-bottom-left"
@@ -306,7 +306,7 @@ exports[`InCallView > rendering > renders 1`] = `
class="_buttons_20b7b4"
>
<button
aria-labelledby="_r_g_"
aria-labelledby="_r_d_"
class="_button_1nw83_8 _settingsOnlyShowNarrow_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -331,7 +331,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_l_"
aria-labelledby="_r_i_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -356,7 +356,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-busy="false"
aria-checked="false"
aria-disabled="true"
aria-labelledby="_r_q_"
aria-labelledby="_r_n_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="primary"
data-size="lg"
@@ -381,7 +381,7 @@ exports[`InCallView > rendering > renders 1`] = `
aria-disabled="false"
aria-expanded="false"
aria-haspopup="true"
aria-labelledby="_r_v_"
aria-labelledby="_r_s_"
class="_button_1nw83_8 _raiseHand_20b7b4 _has-icon_1nw83_60 _icon-only_1nw83_53"
data-kind="secondary"
data-size="lg"
@@ -404,7 +404,7 @@ exports[`InCallView > rendering > renders 1`] = `
</svg>
</button>
<button
aria-labelledby="_r_17_"
aria-labelledby="_r_14_"
class="_button_1nw83_8 _has-icon_1nw83_60 _icon-only_1nw83_53 _destructive_1nw83_110"
data-kind="primary"
data-size="lg"
@@ -432,8 +432,8 @@ exports[`InCallView > rendering > renders 1`] = `
data-size="lg"
>
<input
aria-labelledby="_r_1d_"
name="_r_1c_"
aria-labelledby="_r_1a_"
name="_r_19_"
type="radio"
value="spotlight"
/>
@@ -452,9 +452,9 @@ exports[`InCallView > rendering > renders 1`] = `
/>
</svg>
<input
aria-labelledby="_r_1i_"
aria-labelledby="_r_1f_"
checked=""
name="_r_1c_"
name="_r_19_"
type="radio"
value="grid"
/>
+4 -2
View File
@@ -34,7 +34,7 @@ import {
EndCallIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { widget } from "../widget";
import { useUrlParams } from "../UrlParams";
export type GroupCallLoaded = {
kind: "loaded";
@@ -132,6 +132,7 @@ export const useLoadGroupCall = (
const [state, setState] = useState<GroupCallStatus>({ kind: "loading" });
const activeRoom = useRef<Room | undefined>(undefined);
const { t } = useTranslation();
const { isWidget } = useUrlParams();
const bannedError = useCallback(
(): CallTerminatedMessage =>
@@ -249,7 +250,7 @@ export const useLoadGroupCall = (
// room already joined so we are done here already.
return room!;
}
if (widget)
if (isWidget)
// in widget mode we never should reach this point. (getRoom should return the room.)
throw new Error(
"Room not found. The widget-api did not pass over the relevant room events/information.",
@@ -373,6 +374,7 @@ export const useLoadGroupCall = (
}, [
bannedError,
client,
isWidget,
knockRejectError,
removeNoticeError,
roomIdOrAlias,
+5 -2
View File
@@ -18,7 +18,7 @@ import { ProfileSettingsTab } from "./ProfileSettingsTab";
import { FeedbackSettingsTab } from "./FeedbackSettingsTab";
import { iosDeviceMenu$ } from "../state/MediaDevices";
import { useMediaDevices } from "../MediaDevicesContext";
import { widget } from "../widget";
import { useHostBridge } from "../HostBridge";
import {
useSetting,
soundEffectVolume as soundEffectVolumeSetting,
@@ -123,6 +123,7 @@ export const SettingsModal: FC<Props> = ({
// On EC, we decided that it is less confusing for the user if they see those options in the output section
// rather than the input section.
const { controlledAudioDevices } = useUrlParams();
const hostBridge = useHostBridge();
// If we are on iOS we will show a button to open the native audio device picker.
const iosDeviceMenu = useBehavior(iosDeviceMenu$);
@@ -234,7 +235,9 @@ export const SettingsModal: FC<Props> = ({
};
const tabs = [audioTab, videoTab];
if (widget === null) tabs.push(profileTab);
// A host that can dismiss us is a host that owns the user's account, so their
// profile is not ours to edit.
if (hostBridge.close === undefined) tabs.push(profileTab);
tabs.push(preferencesTab);
if (isRageshakeAvailable || import.meta.env.VITE_PACKAGE === "full") {
// for full package we want to show the analytics consent checkbox
-52
View File
@@ -1,52 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, expect, it, vi } from "vitest";
import { init as initRageshake } from "./rageshake";
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("flushes logs to IndexedDB periodically without an explicit flush", async () => {
vi.useFakeTimers();
const add = vi.fn();
const txn = {
oncomplete: undefined as (() => void) | undefined,
onerror: undefined,
objectStore: (name: string) =>
name === "logs"
? {
add: (entry: unknown): void => {
add(entry);
queueMicrotask(() => txn.oncomplete?.());
},
}
: { put: vi.fn() },
};
const open = (): unknown => {
const req = {
result: { transaction: () => txn },
onsuccess: undefined as (() => void) | undefined,
};
queueMicrotask(() => req.onsuccess?.());
return req;
};
vi.stubGlobal("indexedDB", { open });
await initRageshake();
global.mx_rage_logger.log(1, "test", "hello from the buffer");
expect(add).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2000);
expect(add).toHaveBeenCalledOnce();
expect(add.mock.calls[0][0]).toMatchObject({
lines: expect.stringContaining("hello from the buffer"),
});
});
+4 -7
View File
@@ -204,13 +204,10 @@ class IndexedDBLogStore {
// Throttled function to flush logs. We use throttle rather
// than debounce as we want logs to be written regularly, otherwise
// if there's a constant stream of logging, we'd never write anything.
private throttledFlush = throttle(
() => {
this.flush().catch((e) => logger.error("Failed to flush logs", e));
},
MAX_FLUSH_INTERVAL_MS,
{ leading: false, trailing: true },
);
private throttledFlush = throttle(() => this.flush, MAX_FLUSH_INTERVAL_MS, {
leading: false,
trailing: true,
});
/**
* Flush logs to disk.
+9 -3
View File
@@ -5,17 +5,23 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { MediaDevices } from "./MediaDevices";
import { type AudioOutputOptions, MediaDevices } from "./MediaDevices";
import { type ObservableScope } from "./ObservableScope";
/**
* The top-level state holder for the application.
*/
export class AppViewModel {
public readonly mediaDevices = new MediaDevices(this.scope);
public readonly mediaDevices = new MediaDevices(
this.scope,
this.audioOutputOptions,
);
// TODO: Move more application logic here. The CallViewModel, at the very
// least, ought to be accessible from this object.
public constructor(private readonly scope: ObservableScope) {}
public constructor(
private readonly scope: ObservableScope,
private readonly audioOutputOptions: AudioOutputOptions,
) {}
}
+44 -5
View File
@@ -68,6 +68,8 @@ import {
} from "./CallViewModelTestUtils.ts";
import { MatrixRTCMode } from "../../config/ConfigOptions.ts";
import { initializeWidget } from "../../widget.ts";
import { computeUrlParams } from "../../UrlParams.ts";
import { callViewModelOptionsFromParams } from "./CallViewModel.ts";
initializeWidget();
@@ -83,9 +85,6 @@ vi.mock("livekit-client/e2ee-worker?worker");
vi.mock("../e2ee/matrixKeyProvider");
const getUrlParams = vi.hoisted(() => vi.fn(() => ({})));
vi.mock("../UrlParams", () => ({ getUrlParams }));
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../../Platform", () => ({
get platform(): string {
@@ -1593,12 +1592,13 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
it.skip("audio output changes when toggling earpiece mode", () => {
withTestScheduler(({ schedule, expectObservable }) => {
getUrlParams.mockReturnValue({ controlledAudioDevices: true });
vi.mocked(ComponentsCore.createMediaDeviceObserver).mockReturnValue(
of([]),
);
const devices = new MediaDevices(testScope());
const devices = new MediaDevices(testScope(), {
controlledAudioDevices: true,
});
window.controls.setAvailableAudioDevices([
{ id: "speaker", name: "Speaker", isSpeaker: true },
@@ -1700,3 +1700,42 @@ describe.each(modes)("CallViewModel (%s mode)", (mode) => {
});
});
});
describe("callViewModelOptionsFromParams", () => {
// The defaults on CallViewModelOptions describe a standalone Element Call, so
// a widget caller that drops one of these gets standalone behaviour rather
// than an error. These check the whole chain from URL to options, which is
// where that went wrong for the SDK.
const widgetUrl = (extra: string): string =>
`#?widgetId=id&parentUrl=${encodeURIComponent("http://parent")}&${extra}`;
it("carries an explicitly requested notification type", () => {
const params = computeUrlParams("", widgetUrl("sendNotificationType=ring"));
expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe(
"ring",
);
});
it("carries the notification type an intent implies", () => {
const params = computeUrlParams("", widgetUrl("intent=start_call_dm"));
expect(callViewModelOptionsFromParams(params).sendNotificationType).toBe(
"ring",
);
});
it("carries hideScreensharing", () => {
const params = computeUrlParams("", widgetUrl("hideScreensharing=true"));
expect(callViewModelOptionsFromParams(params).hideScreensharing).toBe(true);
});
it("carries controlledAudioDevices and the call intent", () => {
const params = computeUrlParams(
"",
widgetUrl("controlledAudioDevices=true&intent=start_call_voice"),
);
expect(callViewModelOptionsFromParams(params)).toMatchObject({
controlledAudioDevices: true,
callIntent: "audio",
});
});
});
+86 -29
View File
@@ -45,8 +45,9 @@ import {
MembershipManagerEvent,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import { type IWidgetApiRequest } from "matrix-widget-api";
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager";
import { v4 as uuidv4 } from "uuid";
import { type IMembershipManager } from "matrix-js-sdk/lib/matrixrtc/IMembershipManager";
@@ -85,9 +86,9 @@ import { constant, type Behavior } from "../Behavior";
import { E2eeType } from "../../e2ee/e2eeType";
import { MatrixKeyProvider } from "../../e2ee/matrixKeyProvider";
import { type MuteStates } from "../MuteStates";
import { getUrlParams, HeaderStyle } from "../../UrlParams";
import { HeaderStyle, type UrlParams } from "../../UrlParams";
import { type ProcessorState } from "../../livekit/TrackProcessorContext";
import { ElementWidgetActions, widget } from "../../widget";
import { type HostBridge, nullHostBridge } from "../../HostBridge";
import {
layoutShallowEquals,
type Alignment,
@@ -171,6 +172,28 @@ import { type GridTileViewModel } from "../TileViewModel.ts";
// callMembership -> rtcMembership
export interface CallViewModelOptions {
encryptionSystem: EncryptionSystem;
/**
* The application hosting Element Call, which can ask it to hang up and wants
* to know when the user joins or leaves. Defaults to no host.
*/
hostBridge?: HostBridge;
/**
* Whether the app hosting Element Call controls the audio output devices,
* rather than the browser. Defaults to false.
*/
controlledAudioDevices?: boolean;
/** The style of header to show. Defaults to {@link HeaderStyle.Standard}. */
header?: HeaderStyle;
/** Whether the call controls should be shown. Defaults to true. */
showControls?: boolean;
/** Whether to hide the screen-sharing button. Defaults to false. */
hideScreensharing?: boolean;
/**
* Whether and what kind of notification to send when joining the call.
*/
sendNotificationType?: RTCNotificationType;
/** The kind of call being placed. */
callIntent?: RTCCallIntent;
autoLeaveWhenOthersLeft?: boolean;
/**
* If the call is started in a way where we want it to behave like a telephone usecase
@@ -193,6 +216,40 @@ export interface CallViewModelOptions {
toggleScreensharing?: () => void;
}
/**
* The options {@link createCallViewModel$} takes from the parameters Element
* Call was started with.
*
* Callers share this rather than picking the fields out themselves. The
* defaults on {@link CallViewModelOptions} describe a standalone Element Call,
* so a widget or embedded caller that misses one does not get an error — it
* quietly gets standalone behaviour instead.
*
* Note `autoLeaveWhenOthersLeft` and `waitForCallPickup` are deliberately not
* here: unlike these, the view model never read them from the parameters
* itself, so they remain the caller's decision.
*/
export function callViewModelOptionsFromParams(
params: UrlParams,
): Pick<
CallViewModelOptions,
| "controlledAudioDevices"
| "header"
| "showControls"
| "hideScreensharing"
| "sendNotificationType"
| "callIntent"
> {
return {
controlledAudioDevices: params.controlledAudioDevices,
header: params.header,
showControls: params.showControls,
hideScreensharing: params.hideScreensharing,
sendNotificationType: params.sendNotificationType,
callIntent: params.callIntent,
};
}
// Do not play any sounds if the participant count has exceeded this
// number.
export const MAX_PARTICIPANT_COUNT_FOR_SOUND = 8;
@@ -267,11 +324,6 @@ export interface CallViewModel {
* Whether we are sharing our screen.
*/
sharingScreen$: Behavior<boolean>;
/**
* The last error from toggling screen sharing, until dismissed.
*/
screenShareError$: Behavior<Error | null>;
dismissScreenShareError: () => void;
// UI interactions
/**
@@ -440,6 +492,18 @@ export function createCallViewModel$(
if (!(userId && deviceId))
throw new UnknownCallError(new Error("userId and deviceId are required"));
// Defaults match what the URL parameters resolve to outside of widget mode,
// so that callers which don't care (chiefly tests) behave as they always have.
const {
hostBridge = nullHostBridge,
controlledAudioDevices = false,
header = HeaderStyle.Standard,
showControls = true,
hideScreensharing = false,
sendNotificationType,
callIntent,
} = options;
const livekitKeyProvider = getE2eeKeyProvider(
options.encryptionSystem,
matrixRTCSession,
@@ -528,7 +592,7 @@ export function createCallViewModel$(
mediaDevices,
trackProcessorState$,
livekitKeyProvider,
getUrlParams().controlledAudioDevices,
controlledAudioDevices,
options.livekitRoomFactory,
);
@@ -568,6 +632,8 @@ export function createCallViewModel$(
encryptMedia: livekitKeyProvider !== undefined,
// TODO. This might need to get called again on each change of matrixRTCMode...
matrixRTCMode: mode,
sendNotificationType,
callIntent,
})),
),
);
@@ -597,12 +663,15 @@ export function createCallViewModel$(
logger.getChild(
"[Publisher " + connection.transport.livekit_service_url + "]",
),
controlledAudioDevices,
);
},
connectionManager,
matrixRTCSession,
localTransport$,
roomId: matrixRoom.roomId,
hideScreensharing,
hostBridge,
logger: logger.getChild(`[${Date.now()}]`),
});
@@ -894,24 +963,16 @@ export function createCallViewModel$(
const userHangup$ = new Subject<void>();
const widgetHangup$ =
widget === null
? NEVER
: (
fromEvent(
widget.lazyActions,
ElementWidgetActions.HangupCall,
) as Observable<CustomEvent<IWidgetApiRequest>>
).pipe(
tap((ev) => {
widget!.api.transport.reply(ev.detail, {});
}),
);
const hostHangup$ = hostBridge.hangUp$.pipe(
tap((request) => {
request.reply();
}),
);
const leave$: Observable<"user" | "timeout" | "decline" | "allOthersLeft"> =
merge(
autoLeave$,
merge(userHangup$, widgetHangup$).pipe(map(() => "user" as const)),
merge(userHangup$, hostHangup$).pipe(map(() => "user" as const)),
).pipe(scope.share);
const spotlightSpeaker$ = scope.behavior<UserMediaViewModel | undefined>(
@@ -1462,9 +1523,8 @@ export function createCallViewModel$(
),
);
const urlParams = getUrlParams();
const showFooterUrlParams = !(
urlParams.header === HeaderStyle.None && urlParams.showControls === false
header === HeaderStyle.None && showControls === false
);
const showFooter$ = scope.behavior(
naturallyShowFooter$.pipe(
@@ -1783,8 +1843,7 @@ export function createCallViewModel$(
return {
autoLeave$: autoLeave$,
ringingVm$: ringingMedia$,
ringingStatusLocation:
urlParams.header === HeaderStyle.AppBar ? "app_bar" : "tile",
ringingStatusLocation: header === HeaderStyle.AppBar ? "app_bar" : "tile",
leave$: leave$,
hangup: (): void => userHangup$.next(),
join: localMembership.requestJoinAndPublish,
@@ -1863,8 +1922,6 @@ export function createCallViewModel$(
reconnecting$: localMembership.reconnecting$,
livekitRoomItems$,
connected$: localMembership.connected$,
screenShareError$: localMembership.screenShareError$,
dismissScreenShareError: localMembership.dismissScreenShareError,
};
}
@@ -31,7 +31,6 @@ import {
flushPromises,
mockConfig,
mockLivekitRoom,
mockLocalParticipant,
mockMuteStates,
withTestScheduler,
ownMemberMock,
@@ -42,7 +41,6 @@ import {
enterRTCSession,
PublishState,
TrackState,
watchScreenShareToggle,
} from "./LocalMember";
import {
FailToGetOpenIdToken,
@@ -54,6 +52,7 @@ import { ConnectionManagerData } from "../remoteMembers/ConnectionManager";
import { ConnectionState, type Connection } from "../remoteMembers/Connection";
import { type Publisher } from "./Publisher";
import { initializeWidget } from "../../../widget";
import { nullHostBridge } from "../../../HostBridge";
import {
type LocalTransport,
type LocalTransportWithSFUConfig,
@@ -70,31 +69,6 @@ vi.mock("@livekit/components-core", () => ({
.mockReturnValue(of({ isScreenShareEnabled: false })),
}));
describe("watchScreenShareToggle", () => {
it("reports nothing when the toggle completes", async () => {
const onError = vi.fn();
watchScreenShareToggle(Promise.resolve(), true, logger, onError);
await flushPromises();
expect(onError).not.toHaveBeenCalled();
});
it("reports failures other than the user cancelling", async () => {
const onError = vi.fn();
const e = new Error("NotReadableError");
watchScreenShareToggle(Promise.reject(e), true, logger, onError);
await flushPromises();
expect(onError).toHaveBeenCalledWith(e);
});
it("does not report the user cancelling the picker", async () => {
const onError = vi.fn();
const cancelled = new DOMException("Permission denied", "NotAllowedError");
watchScreenShareToggle(Promise.reject(cancelled), true, logger, onError);
await flushPromises();
expect(onError).not.toHaveBeenCalled();
});
});
describe("LocalMembership", () => {
describe("enterRTCSession", () => {
it("It joins the correct Session", () => {
@@ -226,6 +200,8 @@ describe("LocalMembership", () => {
rtsSession$: constant(RTCMemberStatus.Connected),
},
roomId: "!test-room-id:example.org",
hideScreensharing: false,
hostBridge: nullHostBridge,
};
it("throws error on missing RTC config error", () => {
@@ -881,98 +857,4 @@ describe("LocalMembership", () => {
scope.end();
});
});
describe("toggleScreenSharing", () => {
let originalMediaDevices: MediaDevices | undefined;
beforeAll(() => {
mockConfig();
// Screen sharing is only offered when getDisplayMedia is available.
originalMediaDevices = navigator.mediaDevices;
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getDisplayMedia: vi.fn() },
});
});
afterAll(() => {
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: originalMediaDevices,
});
});
const createMembershipWithConnection = (
connection: Connection | null,
): {
scope: ObservableScope;
localMembership: ReturnType<typeof createLocalMembership$>;
} => {
const scope = new ObservableScope();
const connectionManagerData = new ConnectionManagerData();
if (connection) connectionManagerData.add(connection, []);
const localMembership = createLocalMembership$({
scope,
...defaultCreateLocalMemberValues,
connectionManager: {
connectionManagerData$: constant(new Epoch(connectionManagerData)),
},
localTransport$: new BehaviorSubject({
advertised$: new BehaviorSubject(aTransport),
active$: new BehaviorSubject(aTransportWithSFUConfig),
}),
});
return { scope, localMembership };
};
it("surfaces a failure and clears it on dismiss", async () => {
const error = new Error("NotReadableError");
const setScreenShareEnabled = vi.fn().mockRejectedValue(error);
const connection = {
state$: constant(ConnectionState.LivekitConnected),
transport: aTransport,
livekitRoom: mockLivekitRoom({
localParticipant: mockLocalParticipant({
isScreenShareEnabled: false,
setScreenShareEnabled,
}),
}),
} as unknown as Connection;
const { scope, localMembership } =
createMembershipWithConnection(connection);
await flushPromises();
expect(localMembership.toggleScreenSharing).not.toBeNull();
expect(localMembership.screenShareError$.value).toBeNull();
localMembership.toggleScreenSharing!();
await flushPromises();
expect(setScreenShareEnabled).toHaveBeenCalledWith(
true,
expect.any(Object),
undefined,
);
expect(localMembership.screenShareError$.value).toBe(error);
localMembership.dismissScreenShareError();
expect(localMembership.screenShareError$.value).toBeNull();
scope.end();
});
it("does nothing when there is no local participant", async () => {
// No connection means participant$ never resolves to a participant.
const { scope, localMembership } = createMembershipWithConnection(null);
await flushPromises();
expect(localMembership.toggleScreenSharing).not.toBeNull();
localMembership.toggleScreenSharing!();
await flushPromises();
expect(localMembership.screenShareError$.value).toBeNull();
scope.end();
});
});
});
@@ -20,6 +20,8 @@ import {
type LivekitTransport,
type LivekitTransportConfig,
type MatrixRTCSession,
type RTCCallIntent,
type RTCNotificationType,
} from "matrix-js-sdk/lib/matrixrtc";
import {
BehaviorSubject,
@@ -51,8 +53,8 @@ import {
MembershipManagerError,
UnknownCallError,
} from "../../../utils/errors.ts";
import { ElementWidgetActions, widget } from "../../../widget.ts";
import { getUrlParams } from "../../../UrlParams.ts";
import { type HostBridge } from "../../../HostBridge.ts";
import { PosthogAnalytics } from "../../../analytics/PosthogAnalytics.ts";
import {
advancedScreenShare,
@@ -141,6 +143,10 @@ interface Props {
MatrixRTCSession,
"updateCallIntent" | "leaveRoomSession"
>;
/** Whether to hide the screen-sharing button. */
hideScreensharing: boolean;
/** The application hosting Element Call, to be kept informed of join/leave. */
hostBridge: HostBridge;
logger: Logger;
}
@@ -160,6 +166,8 @@ interface Props {
* @param props.muteStates The mute states for video and audio.
* @param props.matrixRTCSession The matrix RTC session to join.
* @param props.roomId The room ID used as the call identifier in analytics events.
* @param props.hideScreensharing Whether to hide the screen-sharing button.
* @param props.hostBridge The application hosting Element Call.
* @returns
* - publisher: The handle to create tracks and publish them to the room.
* - connected$: the current connection state. Including matrix server and livekit server connection. (only considering the livekit server we are using for our own media publication)
@@ -178,6 +186,8 @@ export const createLocalMembership$ = ({
muteStates,
matrixRTCSession,
roomId,
hideScreensharing,
hostBridge,
}: Props): {
/**
* This request to start audio and video tracks.
@@ -196,11 +206,6 @@ export const createLocalMembership$ = ({
* Callback to toggle screen sharing. If null, screen sharing is not possible.
*/
toggleScreenSharing: (() => void) | null;
/**
* The last error from toggling screen sharing, until dismissed.
*/
screenShareError$: Behavior<Error | null>;
dismissScreenShareError: () => void;
// tracks$: Behavior<LocalTrack[]>;
participant$: Behavior<LocalParticipant | null>;
connection$: Behavior<Connection | null>;
@@ -575,29 +580,24 @@ export const createLocalMembership$ = ({
}
});
// inform the widget about the connect and disconnect intent from the user.
// inform the host about the connect and disconnect intent from the user.
scope
.behavior(joinAndPublishRequested$.pipe(pairwise(), scope.bind()), [
undefined,
joinAndPublishRequested$.value,
])
.subscribe(([prev, current]) => {
if (!widget) return;
// JOIN prev=false (was left) => current-true (now joiend)
if (!prev && current) {
widget.api.transport
.send(ElementWidgetActions.JoinCall, {})
.catch((e) => {
logger.error("Failed to send join action", e);
});
hostBridge.notifyJoined().catch((e) => {
logger.error("Failed to notify the host that we joined", e);
});
}
// LEAVE prev=false (was joined) => current-true (now left)
if (prev && !current) {
widget.api.transport
.send(ElementWidgetActions.HangupCall, {})
.catch((e) => {
logger.error("Failed to send hangup action", e);
});
hostBridge.notifyHungUp().catch((e) => {
logger.error("Failed to notify the host that we hung up", e);
});
}
});
@@ -711,11 +711,10 @@ export const createLocalMembership$ = ({
),
);
const screenShareError$ = new BehaviorSubject<Error | null>(null);
let toggleScreenSharing: (() => void) | null = null;
if (
"getDisplayMedia" in (navigator.mediaDevices ?? {}) &&
!getUrlParams().hideScreensharing
!hideScreensharing
) {
toggleScreenSharing = (): void => {
const screenshareSettings: ScreenShareCaptureOptions = {
@@ -783,18 +782,13 @@ export const createLocalMembership$ = ({
// We also allow screen sharing to be toggled even if the connection
// is still initializing or publishing tracks, because there's no
// technical reason to disallow this. LiveKit will publish if it can.
const participant = participant$.value;
if (!participant) return;
watchScreenShareToggle(
participant.setScreenShareEnabled(
participant$.value
?.setScreenShareEnabled(
targetScreenshareState,
screenshareSettings,
publishOptions,
),
targetScreenshareState,
logger,
(e) => screenShareError$.next(e),
);
)
.catch(logger.error);
};
}
@@ -813,44 +807,11 @@ export const createLocalMembership$ = ({
),
sharingScreen$,
toggleScreenSharing,
screenShareError$,
dismissScreenShareError: () => screenShareError$.next(null),
connection$: localConnection$,
internalLoggerRef: logger,
};
};
/**
* Logs the outcome of a screen share toggle and reports failures.
*
* getDisplayMedia may legitimately take a long time (the user is choosing
* what to share) or never settle at all, so nothing is inferred from silence:
* the request and its completion are logged with the elapsed time so that a
* hang is visible in the logs, and only an explicit rejection is reported.
*
* The user cancelling the picker rejects with a NotAllowedError; that is
* logged but not reported.
*/
export function watchScreenShareToggle(
toggle: Promise<unknown>,
enable: boolean,
logger: Logger,
onError: (e: Error) => void,
): void {
const what = `Screen share ${enable ? "start" : "stop"}`;
const started = Date.now();
const elapsed = (): string => `${Date.now() - started} ms`;
logger.info(`${what} requested`);
toggle.then(
() => logger.info(`${what} completed in ${elapsed()}`),
(e: unknown) => {
logger.error(`${what} failed after ${elapsed()}:`, e);
if (e instanceof DOMException && e.name === "NotAllowedError") return;
onError(e instanceof Error ? e : new Error(String(e)));
},
);
}
export function observeSharingScreen$(p: Participant): Observable<boolean> {
return observeParticipantEvents(
p,
@@ -864,6 +825,10 @@ export function observeSharingScreen$(p: Participant): Observable<boolean> {
interface EnterRTCSessionOptions {
encryptMedia: boolean;
matrixRTCMode: MatrixRTCMode;
/** Whether and what kind of notification to send when joining. */
sendNotificationType?: RTCNotificationType;
/** The kind of call being placed. */
callIntent?: RTCCallIntent;
}
/**
@@ -876,8 +841,10 @@ interface EnterRTCSessionOptions {
* @param rtcSession - The MatrixRTCSession to join.
* @param ownMembershipIdentity - Options for entering the RTC session.
* @param transport - The LivekitTransport to use for this session.
* @param options - `encryptMedia`: Whether to encrypt media `matrixRTCMode`: The Matrix RTC mode to use.
* @throws If the widget could not send ElementWidgetActions.JoinCall action.
* @param options - `encryptMedia`: Whether to encrypt media. `matrixRTCMode`: The
* Matrix RTC mode to use. `sendNotificationType`: Whether and what kind of
* notification to send on join. `callIntent`: The kind of call being placed.
* @throws If the host could not be told that we are joining.
*/
// Exported for unit testing
export function enterRTCSession(
@@ -886,7 +853,12 @@ export function enterRTCSession(
transport: LivekitTransportConfig,
options: EnterRTCSessionOptions,
): void {
const { encryptMedia, matrixRTCMode } = options;
const {
encryptMedia,
matrixRTCMode,
sendNotificationType: notificationType,
callIntent,
} = options;
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(rtcSession.room.roomId);
@@ -895,7 +867,6 @@ export function enterRTCSession(
// groupCallOTelMembership?.onJoinCall();
const { matrix_rtc_session: matrixRtcSessionConfig } = Config.get();
const { sendNotificationType: notificationType, callIntent } = getUrlParams();
const multiSFU =
matrixRTCMode === MatrixRTCMode.Compatibility ||
matrixRTCMode === MatrixRTCMode.Matrix_2_0;
@@ -192,6 +192,7 @@ describe("Publisher", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
});
@@ -309,6 +310,7 @@ describe("Publisher", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
});
afterEach(async () => {
@@ -364,6 +366,7 @@ describe("Bug fix", () => {
muteStates,
constant({ supported: false, processor: undefined }),
logger,
false,
);
audioEnabled$.next(true);
@@ -29,7 +29,7 @@ import {
type ProcessorState,
trackProcessorSync,
} from "../../../livekit/TrackProcessorContext.tsx";
import { getUrlParams } from "../../../UrlParams.ts";
import { observeTrackReference$ } from "../../observeTrackReference";
import { type Connection } from "../remoteMembers/Connection.ts";
import { ObservableScope } from "../../ObservableScope.ts";
@@ -56,6 +56,8 @@ export class Publisher {
* @param muteStates - The mute states for audio and video.
* @param trackerProcessorState$ - The processor state for the video track processor (e.g. background blur).
* @param logger - The logger to use for logging :D.
* @param controlledAudioDevices - Whether the app hosting Element Call
* controls the audio output devices, rather than the browser.
*/
public constructor(
private connection: Pick<Connection, "livekitRoom" | "state$">, //setE2EEEnabled,
@@ -63,8 +65,8 @@ export class Publisher {
private readonly muteStates: MuteStates,
trackerProcessorState$: Behavior<ProcessorState>,
private logger: Logger,
controlledAudioDevices: boolean,
) {
const { controlledAudioDevices } = getUrlParams();
const room = connection.livekitRoom;
room.setE2EEEnabled(room.options.e2ee !== undefined)?.catch((e: Error) => {
@@ -19,17 +19,14 @@ import {
import {
type LocalParticipant,
type RemoteParticipant,
type RemoteTrack,
type RemoteTrackPublication,
type Room as LivekitRoom,
RoomEvent,
Track,
ConnectionState as LivekitConnectionState,
} from "livekit-client";
import fetchMock from "fetch-mock";
import EventEmitter from "events";
import { type IOpenIDToken } from "matrix-js-sdk";
import { logger, type Logger } from "matrix-js-sdk/lib/logger";
import { logger } from "matrix-js-sdk/lib/logger";
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import {
@@ -512,79 +509,6 @@ describe("remote participants", () => {
});
});
describe("remote track logging", () => {
it("logs remote participant and track events on the connection logger", () => {
setupTest();
const info = vi.fn();
const testLogger = {
getChild: (): unknown => testLogger,
info,
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
} as unknown as Logger;
new Connection(
{
client,
roomId: ROOM_ID,
transport: livekitFocus,
scope: testScope,
ownMembershipIdentity: ownMemberMock,
livekitRoomFactory: () => fakeLivekitRoom,
},
testLogger,
);
info.mockClear(); // drop the constructor log line
const bob = mockRemoteParticipant({
identity: "@bob:example.org:DEV111",
sid: "PA_bob",
});
const pub = {
kind: "audio",
source: "microphone",
trackSid: "TR_mic",
isMuted: false,
} as unknown as RemoteTrackPublication;
const messages = (): string[] => info.mock.calls.map((c) => c[0] as string);
fakeLivekitRoom.emit(RoomEvent.ParticipantConnected, bob);
fakeLivekitRoom.emit(
RoomEvent.TrackSubscribed,
{} as RemoteTrack,
pub,
bob,
);
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, bob);
fakeLivekitRoom.emit(
RoomEvent.TrackStreamStateChanged,
pub,
Track.StreamState.Paused,
bob,
);
expect(messages()).toEqual([
"Participant connected: @bob:example.org:DEV111 (PA_bob)",
"Subscribed: audio microphone TR_mic of @bob:example.org:DEV111 muted=false",
"Muted: audio microphone TR_mic of @bob:example.org:DEV111",
"Stream paused: audio microphone TR_mic of @bob:example.org:DEV111",
]);
// Local mute events are already logged by the Publisher
fakeLivekitRoom.emit(RoomEvent.TrackMuted, pub, {
...fakeLocalParticipant,
isLocal: true,
} as unknown as LocalParticipant);
expect(messages().filter((m) => m.startsWith("Muted"))).toHaveLength(1);
// Listeners are removed when the scope ends
testScope.end();
fakeLivekitRoom.emit(RoomEvent.ParticipantDisconnected, bob);
expect(
messages().filter((m) => m.startsWith("Participant disconnected")),
).toHaveLength(0);
});
});
//
// NOT USED ANYMORE ?
//
@@ -13,14 +13,8 @@ import {
import {
ConnectionError,
ConnectionErrorReason,
type Participant,
type RemoteParticipant,
type RemoteTrackPublication,
type Room as LivekitRoom,
RoomEvent,
type SubscriptionError,
type Track,
type TrackPublication,
} from "livekit-client";
import { type LivekitTransportConfig } from "matrix-js-sdk/lib/matrixrtc";
import { BehaviorSubject, map } from "rxjs";
@@ -170,7 +164,6 @@ export class Connection {
// Only tracks remote participants
connectedParticipantsObserver(this.livekitRoom),
);
this.logRemoteTrackEvents();
scope.onEnd(() => {
this.logger.info(`Connection scope ended, stopping connection`);
@@ -178,87 +171,6 @@ export class Connection {
});
}
/**
* Logs the lifecycle of remote participants and their tracks as seen by
* livekit-client, so that rageshakes show what a tile should have been
* rendering (published, subscribed, muted, paused) for each remote member.
*/
private logRemoteTrackEvents(): void {
const room = this.livekitRoom;
const log = this.logger.getChild("[RemoteTracks]");
const track = (pub: TrackPublication, p: Participant): string =>
`${pub.kind} ${pub.source} ${pub.trackSid} of ${p.identity}`;
const onParticipantConnected = (p: RemoteParticipant): void =>
log.info(`Participant connected: ${p.identity} (${p.sid})`);
const onParticipantDisconnected = (p: RemoteParticipant): void =>
log.info(`Participant disconnected: ${p.identity} (${p.sid})`);
const onTrackPublished = (
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Published: ${track(pub, p)} muted=${pub.isMuted}`);
const onTrackUnpublished = (
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Unpublished: ${track(pub, p)}`);
const onTrackSubscribed = (
_t: Track,
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Subscribed: ${track(pub, p)} muted=${pub.isMuted}`);
const onTrackUnsubscribed = (
_t: Track,
pub: RemoteTrackPublication,
p: RemoteParticipant,
): void => log.info(`Unsubscribed: ${track(pub, p)}`);
const onTrackSubscriptionFailed = (
trackSid: string,
p: RemoteParticipant,
reason?: SubscriptionError,
): void =>
log.warn(
`Subscription failed: ${trackSid} of ${p.identity} reason=${reason}`,
);
// Mute events also fire for the local participant, which the Publisher
// already logs.
const onTrackMuted = (pub: TrackPublication, p: Participant): void => {
if (!p.isLocal) log.info(`Muted: ${track(pub, p)}`);
};
const onTrackUnmuted = (pub: TrackPublication, p: Participant): void => {
if (!p.isLocal) log.info(`Unmuted: ${track(pub, p)}`);
};
const onTrackStreamStateChanged = (
pub: RemoteTrackPublication,
state: Track.StreamState,
p: RemoteParticipant,
): void => log.info(`Stream ${state}: ${track(pub, p)}`);
room
.on(RoomEvent.ParticipantConnected, onParticipantConnected)
.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
.on(RoomEvent.TrackPublished, onTrackPublished)
.on(RoomEvent.TrackUnpublished, onTrackUnpublished)
.on(RoomEvent.TrackSubscribed, onTrackSubscribed)
.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.on(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.on(RoomEvent.TrackMuted, onTrackMuted)
.on(RoomEvent.TrackUnmuted, onTrackUnmuted)
.on(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
this.scope.onEnd(() => {
room
.off(RoomEvent.ParticipantConnected, onParticipantConnected)
.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected)
.off(RoomEvent.TrackPublished, onTrackPublished)
.off(RoomEvent.TrackUnpublished, onTrackUnpublished)
.off(RoomEvent.TrackSubscribed, onTrackSubscribed)
.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed)
.off(RoomEvent.TrackSubscriptionFailed, onTrackSubscriptionFailed)
.off(RoomEvent.TrackMuted, onTrackMuted)
.off(RoomEvent.TrackUnmuted, onTrackUnmuted)
.off(RoomEvent.TrackStreamStateChanged, onTrackStreamStateChanged);
});
}
/**
* Starts the connection.
*
@@ -14,7 +14,7 @@ import { logger } from "matrix-js-sdk/lib/logger";
import { Epoch, mapEpoch, ObservableScope } from "../../ObservableScope.ts";
import {
createConnectionManager$,
ConnectionManagerData,
type ConnectionManagerData,
} from "./ConnectionManager.ts";
import { type ConnectionFactory } from "./ConnectionFactory.ts";
import { type Connection } from "./Connection.ts";
@@ -203,24 +203,6 @@ describe("connections$ stream", () => {
});
});
describe("ConnectionManagerData", () => {
test("warns when a second connection to the same URL is merged", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const data = new ConnectionManagerData(logger);
const connection = { transport: TRANSPORT_1 } as unknown as Connection;
const p = (identity: string): RemoteParticipant =>
({ identity }) as unknown as RemoteParticipant;
data.add(connection, [p("a")]);
data.add({ ...connection } as Connection, [p("b")]);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining(
"second connection to https://lk.example.org: existing [a], adding [b]",
),
);
expect(data.getParticipantsForTransport(TRANSPORT_1)).toHaveLength(2);
});
});
describe("connectionManagerData$ stream", () => {
// Used in test to control fake connections' remoteParticipants$ streams
let fakeRemoteParticipantsStreams: Map<string, Behavior<RemoteParticipant[]>>;
@@ -30,7 +30,7 @@ export class ConnectionManagerData {
{ connection: Connection; participants: RemoteParticipant[] }
> = new Map();
public constructor(private readonly logger?: Logger) {}
public constructor() {}
public add(connection: Connection, participants: RemoteParticipant[]): void {
const key = this.getKey(connection.transport);
@@ -38,11 +38,6 @@ export class ConnectionManagerData {
if (!existing) {
this.store.set(key, { connection, participants });
} else {
// Transports are deduplicated by URL upstream, so this should never
// happen; if it does, members may be matched against the wrong room.
this.logger?.warn(
`Merging participants from a second connection to ${key}: existing [${existing.participants.map((p) => p.identity).join(", ")}], adding [${participants.map((p) => p.identity).join(", ")}]`,
);
existing.participants.push(...participants);
}
}
@@ -244,7 +239,7 @@ export function createConnectionManager$({
lists.reduce((data, { connection, participants }) => {
data.add(connection, participants);
return data;
}, new ConnectionManagerData(logger)),
}, new ConnectionManagerData()),
epoch,
),
),
@@ -5,8 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, test, expect, beforeEach, afterEach, vi } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { describe, test, expect, beforeEach, afterEach } from "vitest";
import {
type CallMembership,
type LivekitTransport,
@@ -78,7 +77,6 @@ function epochMeWith$<T, U>(
}
test("should signal participant not yet connected to livekit", async () => {
const info = vi.spyOn(logger, "info");
const mockedMemberships$ = new BehaviorSubject([bobMembership]);
const mockConnectionManagerData$ = new BehaviorSubject(
new ConnectionManagerData(),
@@ -110,9 +108,6 @@ test("should signal participant not yet connected to livekit", async () => {
return true;
},
);
expect(info).toHaveBeenCalledWith(
"[RemoteMatrixLivekitMembers] @bob:example.org:DEV000: LiveKit participant missing on no connection",
);
});
// Helper to create epoch'ed memberships$ and membershipsWithTransport$ from memberships observable.
@@ -11,7 +11,6 @@ import {
type LivekitTransportConfig,
} from "matrix-js-sdk/lib/matrixrtc";
import { combineLatest, filter, map } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../../Behavior";
import { type IConnectionManager } from "./ConnectionManager";
@@ -111,17 +110,13 @@ export function createRemoteMatrixLivekitMembers$({
const participants = transport
? managerData.getParticipantsForTransport(transport)
: [];
const matches = participants.filter(
(p) => p.identity == membership.rtcBackendIdentity,
);
const participant = matches[0] ?? null;
const participant =
participants.find(
(p) => p.identity == membership.rtcBackendIdentity,
) ?? null;
const connection = transport
? managerData.getConnectionForTransport(transport)
: null;
if (matches.length > 1)
logger.warn(
`[RemoteMatrixLivekitMembers] ${membership.rtcBackendIdentity}: ${matches.length} LiveKit participants match (sids ${matches.map((p) => p.sid).join(", ")}), using ${participant?.sid}`,
);
yield {
// This could just be the backend identity without the other keys.
@@ -138,16 +133,8 @@ export function createRemoteMatrixLivekitMembers$({
}
},
// Each update where the key of the generator array do not change will result in updates to the `data$` behavior.
(scope, data$, userId, _deviceId, _memberId, rtcBackendIdentity) => {
(scope, data$, userId, _deviceId, _memberId, _rtcBackendIdentity) => {
const { participant$, ...rest } = scope.splitBehavior(data$);
// Log whether the member could be matched to a LiveKit participant,
// since a tile shows "waiting for media" for as long as it cannot.
participant$.pipe(scope.bind()).subscribe((p) => {
const url = data$.value.connection?.transport.livekit_service_url;
logger.info(
`[RemoteMatrixLivekitMembers] ${rtcBackendIdentity}: LiveKit participant ${p ? `matched (${p.sid})` : "missing"} on ${url ?? "no connection"}`,
);
});
// will only get called once per backend identity.
// updates to data$ and as a result to displayName$ and mxcAvatarUrl$ are more frequent.
return {
+15 -31
View File
@@ -6,39 +6,31 @@ Please see LICENSE in the repository root for full details.
*/
import { it, vi, expect } from "vitest";
import EventEmitter from "events";
import { Subject } from "rxjs";
// import * as ComponentsCore from "@livekit/components-core";
import { withCallViewModel } from "./CallViewModel/CallViewModelTestUtils.ts";
import { type CallViewModel } from "./CallViewModel/CallViewModel.ts";
import { constant } from "./Behavior.ts";
import { aliceParticipant, localRtcMember } from "../utils/test-fixtures.ts";
import { ElementWidgetActions, widget } from "../widget.ts";
import {
type HostBridge,
type HostRequest,
nullHostBridge,
} from "../HostBridge.ts";
import { E2eeType } from "../e2ee/e2eeType.ts";
import { MatrixRTCMode } from "../config/ConfigOptions.ts";
vi.mock("@livekit/components-core", { spy: true });
vi.mock("../widget", () => ({
ElementWidgetActions: {
HangupCall: "HangupCall",
// Add other actions if needed
},
widget: {
api: {
transport: {
send: vi.fn().mockResolvedValue(undefined),
reply: vi.fn().mockResolvedValue(undefined),
},
},
lazyActions: new EventEmitter(),
},
}));
it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
"expect leave when ElementWidgetActions.HangupCall is called (%s mode)",
"expect leave when the host asks us to hang up (%s mode)",
async (mode) => {
const pr = Promise.withResolvers<string>();
const hangUp$ = new Subject<HostRequest<Record<string, never>>>();
const hostBridge: HostBridge = { ...nullHostBridge, hangUp$ };
const reply = vi.fn();
withCallViewModel(mode)(
{
remoteParticipants$: constant([aliceParticipant]),
@@ -49,25 +41,17 @@ it.each([[MatrixRTCMode.Compatibility], [MatrixRTCMode.Matrix_2_0]])(
pr.resolve(s);
});
widget!.lazyActions!.emit(
ElementWidgetActions.HangupCall,
new CustomEvent(ElementWidgetActions.HangupCall, {
detail: {
action: "im.vector.hangup",
api: "toWidget",
data: {},
requestId: "widgetapi-1761237395918",
widgetId: "mrUjS9T6uKUOWHMxXvLbSv0F",
},
}),
);
hangUp$.next({ data: {}, reply });
},
{
encryptionSystem: { kind: E2eeType.PER_PARTICIPANT },
hostBridge,
},
);
const source = await pr.promise;
expect(source).toBe("user");
// The host expects to hear back that we acted on its request
expect(reply).toHaveBeenCalledOnce();
},
);
+59
View File
@@ -0,0 +1,59 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { of } from "rxjs";
const getPlatform = vi.hoisted(() => vi.fn(() => "desktop"));
vi.mock("../Platform", () => ({
get platform(): string {
return getPlatform();
},
isFirefox: (): boolean => false,
}));
vi.mock("@livekit/components-core", () => ({
createMediaDeviceObserver: () => of([]),
}));
import { AudioOutput, MediaDevices } from "./MediaDevices";
import { AndroidControlledAudioOutput } from "./AndroidControlledAudioOutput";
import { IOSControlledAudioOutput } from "./IOSControlledAudioOutput";
import { ObservableScope } from "./ObservableScope";
// Which audio output implementation is used is decided by what the app hosting
// Element Call told it, rather than being discovered from the environment.
describe("MediaDevices audio output", () => {
test("uses the browser's own output when nobody else is controlling it", () => {
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: false,
});
expect(devices.audioOutput).toBeInstanceOf(AudioOutput);
});
test("hands control to the host on Android", () => {
getPlatform.mockReturnValue("android");
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: true,
callIntent: "audio",
});
expect(devices.audioOutput).toBeInstanceOf(AndroidControlledAudioOutput);
});
test("hands control to the host elsewhere too", () => {
getPlatform.mockReturnValue("ios");
const devices = new MediaDevices(new ObservableScope(), {
controlledAudioDevices: true,
callIntent: "video",
});
expect(devices.audioOutput).toBeInstanceOf(IOSControlledAudioOutput);
});
});
+26 -5
View File
@@ -16,6 +16,7 @@ import {
} from "rxjs";
import { createMediaDeviceObserver } from "@livekit/components-core";
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { type RTCCallIntent } from "matrix-js-sdk/lib/matrixrtc";
import {
alwaysShowIphoneEarpiece as alwaysShowIphoneEarpieceSetting,
@@ -25,7 +26,6 @@ import {
} from "../settings/settings";
import { type ObservableScope } from "./ObservableScope";
import { availableOutputDevices$ as controlledAvailableOutputDevices$ } from "../controls";
import { getUrlParams } from "../UrlParams";
import { platform } from "../Platform";
import { switchWhen } from "../utils/observable";
import { type Behavior, constant } from "./Behavior";
@@ -338,6 +338,22 @@ class VideoInput implements MediaDevice<DeviceLabel, SelectedDevice> {
}
}
/**
* How Element Call should manage audio output.
*/
export interface AudioOutputOptions {
/**
* Whether the list of output devices is controlled by the app hosting Element
* Call, through the global JS controls, rather than by the browser.
*/
controlledAudioDevices: boolean;
/**
* The kind of call being placed, which decides the initial output route when
* the host controls the devices.
*/
callIntent?: RTCCallIntent;
}
export class MediaDevices {
private readonly deviceNamesRequest$ = new Subject<void>();
/**
@@ -368,23 +384,28 @@ export class MediaDevices {
public readonly audioOutput: MediaDevice<
AudioOutputDeviceLabel,
SelectedAudioOutputDevice
> = getUrlParams().controlledAudioDevices
> = this.audioOutputOptions.controlledAudioDevices
? platform == "android"
? new AndroidControlledAudioOutput(
controlledAvailableOutputDevices$,
this.scope,
getUrlParams().callIntent,
this.audioOutputOptions.callIntent,
window.controls,
)
: new IOSControlledAudioOutput(
this.usingNames$,
this.scope,
getUrlParams().callIntent,
this.audioOutputOptions.callIntent,
)
: new AudioOutput(this.usingNames$, this.scope);
public readonly videoInput: MediaDevice<DeviceLabel, SelectedDevice> =
new VideoInput(this.usingNames$, this.scope);
public constructor(private readonly scope: ObservableScope) {}
// Note: both parameters are read by the field initializers above, which is
// safe because TypeScript assigns parameter properties before running them.
public constructor(
private readonly scope: ObservableScope,
private readonly audioOutputOptions: AudioOutputOptions,
) {}
}
+7 -4
View File
@@ -9,6 +9,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BehaviorSubject } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { nullHostBridge } from "../HostBridge";
import { MuteStates, MuteState } from "./MuteStates";
import {
type AudioOutputDeviceLabel,
@@ -228,10 +229,12 @@ describe("MuteStates", () => {
videoInput: aVideoInput(),
// other devices are not relevant for this test
});
const muteStates = new MuteStates(testScope, mediaDevices, {
audioEnabled: false,
videoEnabled: false,
});
const muteStates = new MuteStates(
testScope,
mediaDevices,
{ audioEnabled: false, videoEnabled: false },
nullHostBridge,
);
let latestSyncedState: boolean | null = null;
muteStates.video.setHandler(async (enabled: boolean): Promise<boolean> => {
+45 -54
View File
@@ -6,14 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type IWidgetApiRequest } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/lib/logger";
import {
BehaviorSubject,
combineLatest,
distinctUntilChanged,
firstValueFrom,
fromEvent,
map,
merge,
Observable,
@@ -24,7 +22,7 @@ import {
} from "rxjs";
import { type MediaDevices, type MediaDevice } from "../state/MediaDevices";
import { ElementWidgetActions, widget } from "../widget";
import { type DeviceMuteState, type HostBridge } from "../HostBridge";
import { type ObservableScope } from "./ObservableScope";
import { type Behavior, constant } from "./Behavior";
@@ -216,58 +214,51 @@ export class MuteStates {
audioEnabled: boolean;
videoEnabled: boolean;
},
hostBridge: HostBridge,
) {
if (widget !== null) {
// Sync our mute states with the hosting client
const widgetApiState$ = combineLatest(
[this.audio.enabled$, this.video.enabled$],
(audio, video) => ({ audio_enabled: audio, video_enabled: video }),
);
widgetApiState$.pipe(this.scope.bind()).subscribe((state) => {
widget!.api.transport
.send(ElementWidgetActions.DeviceMute, state)
.catch((e) =>
logger.warn("Could not send DeviceMute action to widget", e),
);
});
// Keep the host informed of our mute state
const muteState$ = combineLatest(
[this.audio.enabled$, this.video.enabled$],
(audio, video): DeviceMuteState => ({
audio_enabled: audio,
video_enabled: video,
}),
);
muteState$.pipe(this.scope.bind()).subscribe((state) => {
hostBridge
.notifyDeviceMute(state)
.catch((e) => logger.warn("Could not send mute state to the host", e));
});
// Also sync the hosting client's mute states back with ours
const muteActions$ = fromEvent(
widget.lazyActions,
ElementWidgetActions.DeviceMute,
) as Observable<CustomEvent<IWidgetApiRequest>>;
muteActions$
.pipe(
withLatestFrom(
widgetApiState$,
this.audio.setEnabled$,
this.video.setEnabled$,
),
this.scope.bind(),
)
.subscribe(([ev, state, setAudioEnabled, setVideoEnabled]) => {
// First copy the current state into our new state
const newState = { ...state };
// Update new state if there are any requested changes from the widget
// action in `ev.detail.data`.
if (
ev.detail.data.audio_enabled != null &&
typeof ev.detail.data.audio_enabled === "boolean" &&
setAudioEnabled !== null
) {
newState.audio_enabled = ev.detail.data.audio_enabled;
setAudioEnabled(newState.audio_enabled);
}
if (
ev.detail.data.video_enabled != null &&
typeof ev.detail.data.video_enabled === "boolean" &&
setVideoEnabled !== null
) {
newState.video_enabled = ev.detail.data.video_enabled;
setVideoEnabled(newState.video_enabled);
}
widget!.api.transport.reply(ev.detail, newState);
});
}
// And apply the changes the host asks for
hostBridge.deviceMute$
.pipe(
withLatestFrom(
muteState$,
this.audio.setEnabled$,
this.video.setEnabled$,
),
this.scope.bind(),
)
.subscribe(([request, state, setAudioEnabled, setVideoEnabled]) => {
// First copy the current state into our new state
const newState = { ...state };
// Then apply whichever changes the host asked for
if (
typeof request.data.audio_enabled === "boolean" &&
setAudioEnabled !== null
) {
newState.audio_enabled = request.data.audio_enabled;
setAudioEnabled(newState.audio_enabled);
}
if (
typeof request.data.video_enabled === "boolean" &&
setVideoEnabled !== null
) {
newState.video_enabled = request.data.video_enabled;
setVideoEnabled(newState.video_enabled);
}
request.reply(newState);
});
}
}
+5 -59
View File
@@ -16,7 +16,7 @@ import {
ObservableScope,
trackEpoch,
} from "./ObservableScope";
import { testScope, withTestScheduler } from "../utils/test";
import { withTestScheduler } from "../utils/test";
describe("Epoch", () => {
it("should map the value correctly", () => {
@@ -87,7 +87,7 @@ describe("Epoch", () => {
});
it("behavior test", () => {
const scope = testScope();
const scope = new ObservableScope();
const s$ = new Subject();
const behavior$ = scope.behavior(s$, 0);
behavior$.subscribe((value) => {
@@ -115,7 +115,7 @@ describe("Reconcile", () => {
it("should wait clean up before processing next", async () => {
vi.useFakeTimers();
const scope = testScope();
const scope = new ObservableScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi.fn().mockImplementation(async () => await sleep(100));
@@ -149,7 +149,7 @@ describe("Reconcile", () => {
it("should skip intermediates values that are not setup", async () => {
vi.useFakeTimers();
const scope = testScope();
const scope = new ObservableScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi
@@ -194,7 +194,7 @@ describe("Reconcile", () => {
it("should wait for setup to complete before starting cleanup", async () => {
vi.useFakeTimers();
const scope = testScope();
const scope = new ObservableScope();
const behavior$ = new BehaviorSubject<number>(0);
const setup = vi
@@ -237,57 +237,3 @@ describe("Reconcile", () => {
expect(setup).toHaveBeenCalledWith(1);
});
});
describe("behavior", () => {
it("warns with the tag and nesting depth, logging each new depth once", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const scope = testScope();
const source$ = new Subject<number>();
const behavior$ = scope.behavior(source$, 0, "tagged");
// Re-enter once on value 1 (depth 2), then a nested re-entry on value 2
// (depth 3), which is the shape needed to strand a middle subscriber.
behavior$.subscribe((v) => {
if (v === 1) source$.next(2);
});
behavior$.subscribe((v) => {
if (v === 2) source$.next(3);
});
const seen: number[] = [];
behavior$.subscribe((v) => seen.push(v));
source$.next(1);
const messages = warn.mock.calls.map((c) => c[0] as string);
expect(messages).toEqual([
expect.stringContaining("Behavior (tagged) re-entered at depth 2"),
expect.stringContaining("Behavior (tagged) re-entered at depth 3"),
]);
warn.mock.calls.forEach((c) => expect(c[1]).toEqual(expect.any(String)));
// The behavior settles on the newest value while the last subscriber is
// left stranded on the oldest.
expect(behavior$.value).toBe(3);
expect(seen.at(-1)).toBe(1);
});
it("warns when the scope is ended while a behavior is mid-delivery", () => {
const warn = vi.spyOn(logger, "warn").mockImplementation(() => {});
const scope = new ObservableScope();
const source$ = new Subject<number>();
const behavior$ = scope.behavior(source$, 0);
behavior$.subscribe((v) => {
if (v === 1) scope.end();
});
// A derived behavior is bound to the scope, so ending the scope
// mid-delivery unsubscribes it before the value reaches it.
const derived$ = scope.behavior(behavior$);
source$.next(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("Scope ended while 1 of its behaviors"),
expect.any(String),
);
expect(behavior$.value).toBe(1);
expect(derived$.value).toBe(0);
});
});
+4 -50
View File
@@ -20,8 +20,6 @@ import {
takeUntil,
} from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "./Behavior";
type MonoTypeOperator = <T>(o: Observable<T>) => Observable<T>;
@@ -36,12 +34,6 @@ const nothing = Symbol("nothing");
* A scope which limits the execution lifetime of its bound Observables.
*/
export class ObservableScope {
/**
* Number of this scope's behaviors currently mid-delivery. Used to detect a
* scope being torn down synchronously from within one of its own emissions.
*/
private delivering = 0;
private readonly ended$ = new BehaviorSubject(false);
private readonly bindImpl: MonoTypeOperator = takeUntil(
@@ -74,9 +66,6 @@ export class ObservableScope {
public behavior<T>(
setValue$: Observable<T>,
initialValue: T | typeof nothing = nothing,
// An optional label used only in the re-entrancy warning below, so that a
// torn behavior can be identified from a rageshake.
tag?: string,
): Behavior<T> {
const subject$ = new BehaviorSubject(initialValue);
// Push values from the Observable into the BehaviorSubject.
@@ -84,37 +73,11 @@ export class ObservableScope {
// they will no longer re-emit their current value upon subscription. We want
// to support Observables that complete (for example `of({})`), so we have to
// take care to not propagate the completion event.
// If a subscriber synchronously causes this same behavior to emit again,
// rxjs delivers the nested value to every subscriber first and then
// resumes delivering the outer (older) value to the remaining subscribers,
// leaving them permanently out of sync with the others. A single such
// re-entry can only strand a contiguous run of subscribers; stranding a
// subscriber in the middle of the list needs a nested (deeper) re-entry, so
// we report the depth and log each new depth (with a stack trace) rather
// than only the first occurrence, to make a nested re-entry visible.
let depth = 0;
let maxReportedDepth = 1;
setValue$.pipe(this.bind(), distinctUntilChanged()).subscribe({
next: (value) => {
if (depth > 0 && depth + 1 > maxReportedDepth) {
maxReportedDepth = depth + 1;
logger.warn(
`Behavior${tag ? ` (${tag})` : ""} re-entered at depth ${
depth + 1
} while delivering a value; later subscribers will be left with a stale value`,
new Error().stack,
);
}
depth++;
this.delivering++;
try {
subject$.next(value);
} finally {
depth--;
this.delivering--;
}
next(value) {
subject$.next(value);
},
error: (err: unknown) => {
error(err: unknown) {
subject$.error(err);
},
});
@@ -127,11 +90,6 @@ export class ObservableScope {
* Ends the scope, causing any bound Observables to complete.
*/
public end(): void {
if (this.delivering > 0)
logger.warn(
`Scope ended while ${this.delivering} of its behaviors were still delivering a value; later subscribers will be left with a stale value`,
new Error().stack,
);
this.ended$.next(true);
}
@@ -213,11 +171,7 @@ export class ObservableScope {
return Object.fromEntries(
Object.keys(input$.value).map((key) => [
`${key}$`,
this.behavior(
input$.pipe(map((input) => input[key as keyof T])),
nothing,
key,
),
this.behavior(input$.pipe(map((input) => input[key as keyof T]))),
]),
) as SplitBehavior<T>;
}
+10 -16
View File
@@ -8,7 +8,6 @@ Please see LICENSE in the repository root for full details.
import { type RemoteParticipant } from "livekit-client";
import { combineLatest, map, of, switchMap } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../Behavior";
import { createVolumeControls, type VolumeControls } from "../VolumeControls";
@@ -46,20 +45,6 @@ export function createRemoteUserMedia(
statsType: "inbound-rtp",
});
const waitingForMedia$ = scope.behavior(
combineLatest(
[inputs.livekitRoom$, inputs.participant$],
(livekitRoom, participant) =>
// If livekitRoom is undefined, the user is not attempting to publish on
// any transport and so we shouldn't expect a participant. (They might
// be a subscribe-only bot for example.)
livekitRoom !== undefined && participant === null,
),
);
waitingForMedia$.pipe(scope.bind()).subscribe((waiting) => {
logger.info(`[RemoteUserMedia ${inputs.id}] waitingForMedia=${waiting}`);
});
return {
...baseUserMedia,
...createVolumeControls(scope, {
@@ -83,6 +68,15 @@ export function createRemoteUserMedia(
),
),
),
waitingForMedia$,
waitingForMedia$: scope.behavior(
combineLatest(
[inputs.livekitRoom$, inputs.participant$],
(livekitRoom, participant) =>
// If livekitRoom is undefined, the user is not attempting to publish on
// any transport and so we shouldn't expect a participant. (They might
// be a subscribe-only bot for example.)
livekitRoom !== undefined && participant === null,
),
),
};
}
+4 -4
View File
@@ -54,6 +54,7 @@ import { Slider } from "../Slider";
import { platform } from "../Platform";
import { type RingingMediaViewModel } from "../state/media/RingingMediaViewModel";
import { RingingStatus } from "./RingingStatus";
import { useRootElement } from "../RootElementContext";
interface SpotlightItemBaseProps {
ref?: Ref<HTMLDivElement>;
@@ -414,6 +415,7 @@ export const SpotlightTile: FC<Props> = ({
style,
}) => {
const { t } = useTranslation();
const rootElement = useRootElement();
const [ourRef, root$] = useObservableRef<HTMLDivElement | null>(null);
const ref = useMergedRefs(ourRef, theirRef);
const maximised = useBehavior(vm.maximised$);
@@ -428,24 +430,22 @@ export const SpotlightTile: FC<Props> = ({
const canGoToNext = visibleIndex !== -1 && visibleIndex < media.length - 1;
const isFullscreen = useCallback((): boolean => {
const rootElement = document.body;
if (rootElement && document.fullscreenElement) return true;
return false;
}, []);
}, [rootElement]);
const FullScreenIcon = isFullscreen()
? FullScreenMinimiseIcon
: FullScreenMaximiseIcon;
const onToggleFullscreen = useCallback(() => {
const rootElement = document.body;
if (!rootElement) return;
if (isFullscreen()) {
void document?.exitFullscreen();
} else {
void rootElement.requestFullscreen();
}
}, [isFullscreen]);
}, [isFullscreen, rootElement]);
// To keep track of which item is visible, we need an intersection observer
// hooked up to the root element and the items. Because the items will run
+26 -25
View File
@@ -6,6 +6,8 @@ Please see LICENSE in the repository root for full details.
*/
import { act, renderHook } from "@testing-library/react";
import { createElement, type FC, type PropsWithChildren } from "react";
import { Subject } from "rxjs";
import {
afterEach,
beforeEach,
@@ -15,31 +17,35 @@ import {
test,
vi,
} from "vitest";
import EventEmitter from "events";
import { WidgetApiToWidgetAction } from "matrix-widget-api";
import { useTheme } from "./useTheme";
import { getUrlParams } from "./UrlParams";
import { widget } from "./widget";
import { useUrlParams } from "./UrlParams";
import {
type HostBridge,
HostBridgeProvider,
type HostRequest,
nullHostBridge,
} from "./HostBridge";
vi.mock("./UrlParams", () => ({ getUrlParams: vi.fn() }));
vi.mock("./widget", () => ({
widget: {
api: { transport: { reply: vi.fn() } },
lazyActions: new EventEmitter(),
},
}));
vi.mock("./UrlParams", () => ({ useUrlParams: vi.fn() }));
describe("useTheme", () => {
let originalClassList: DOMTokenList;
let themeChange$: Subject<HostRequest<{ name?: string }>>;
let wrapper: FC<PropsWithChildren>;
beforeEach(() => {
themeChange$ = new Subject();
const hostBridge: HostBridge = { ...nullHostBridge, themeChange$ };
wrapper = ({ children }) =>
createElement(HostBridgeProvider, { value: hostBridge }, children);
// Save the original classList to setup spies
originalClassList = document.body.classList;
vi.spyOn(originalClassList, "add");
vi.spyOn(originalClassList, "remove");
vi.spyOn(originalClassList, "item").mockReturnValue(null);
(getUrlParams as Mock).mockReturnValue({ theme: "dark" });
(useUrlParams as Mock).mockReturnValue({ theme: "dark" });
});
afterEach(() => {
@@ -53,9 +59,9 @@ describe("useTheme", () => {
{ setTheme: "light-high-contrast", add: ["cpd-theme-light-hc"] },
])("apply procedure", ({ setTheme, add }) => {
test(`should apply ${add[0]} theme when ${setTheme} theme is specified`, () => {
(getUrlParams as Mock).mockReturnValue({ theme: setTheme });
(useUrlParams as Mock).mockReturnValue({ theme: setTheme });
renderHook(() => useTheme());
renderHook(() => useTheme(), { wrapper });
expect(originalClassList.remove).toHaveBeenCalledWith(
"cpd-theme-light",
@@ -71,7 +77,7 @@ describe("useTheme", () => {
// Simulate a previous theme
originalClassList.item = vi.fn().mockReturnValue("cpd-theme-dark");
renderHook(() => useTheme());
renderHook(() => useTheme(), { wrapper });
expect(document.body.classList.add).not.toHaveBeenCalledWith(
"cpd-theme-dark",
@@ -82,18 +88,13 @@ describe("useTheme", () => {
expect(originalClassList.add).not.toHaveBeenCalled();
});
test("theme changes in response to widget actions", async () => {
renderHook(() => useTheme());
test("theme changes in response to host requests", () => {
renderHook(() => useTheme(), { wrapper });
expect(originalClassList.add).toHaveBeenCalledWith("cpd-theme-dark");
await act(() =>
widget!.lazyActions.emit(
WidgetApiToWidgetAction.ThemeChange,
new CustomEvent(WidgetApiToWidgetAction.ThemeChange, {
detail: { data: { name: "light" } },
}),
),
);
const reply = vi.fn();
act(() => themeChange$.next({ data: { name: "light" }, reply }));
expect(reply).toHaveBeenCalledOnce();
expect(originalClassList.remove).toHaveBeenCalledWith(
"cpd-theme-light",
"cpd-theme-dark",
+20 -31
View File
@@ -6,38 +6,27 @@ Please see LICENSE in the repository root for full details.
*/
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { WidgetApiToWidgetAction } from "matrix-widget-api";
import { type IThemeChangeActionRequest } from "matrix-widget-api";
import { getUrlParams } from "./UrlParams";
import { widget } from "./widget";
import { useUrlParams } from "./UrlParams";
import { useRootElement } from "./RootElementContext";
import { useHostBridge } from "./HostBridge";
export const useTheme = (): void => {
const [requestedTheme, setRequestedTheme] = useState(
() => getUrlParams().theme,
);
const previousTheme = useRef<string | null>(document.body.classList.item(0));
const rootElement = useRootElement();
const hostBridge = useHostBridge();
const { theme } = useUrlParams();
const [requestedTheme, setRequestedTheme] = useState(theme);
const previousTheme = useRef<string | null>(rootElement.classList.item(0));
useEffect(() => {
if (widget) {
const onThemeChange = (
ev: CustomEvent<IThemeChangeActionRequest>,
): void => {
ev.preventDefault();
if ("name" in ev.detail.data && typeof ev.detail.data.name === "string")
setRequestedTheme(ev.detail.data.name);
widget!.api.transport.reply(ev.detail, {});
};
widget.lazyActions.on(WidgetApiToWidgetAction.ThemeChange, onThemeChange);
return (): void => {
widget!.lazyActions.off(
WidgetApiToWidgetAction.ThemeChange,
onThemeChange,
);
};
}
}, []);
const subscription = hostBridge.themeChange$.subscribe(
({ data, reply }) => {
if (typeof data.name === "string") setRequestedTheme(data.name);
reply();
},
);
return (): void => subscription.unsubscribe();
}, [hostBridge]);
useLayoutEffect(() => {
// If no theme has been explicitly requested we default to dark
@@ -47,15 +36,15 @@ export const useTheme = (): void => {
: "";
const themeString = "cpd-theme-" + theme + themeHighContrast;
if (themeString !== previousTheme.current) {
document.body.classList.remove(
rootElement.classList.remove(
"cpd-theme-light",
"cpd-theme-dark",
"cpd-theme-light-hc",
"cpd-theme-dark-hc",
);
document.body.classList.add(themeString);
rootElement.classList.add(themeString);
previousTheme.current = themeString;
}
document.body.classList.remove("no-theme");
}, [previousTheme, requestedTheme]);
rootElement.classList.remove("no-theme");
}, [previousTheme, requestedTheme, rootElement]);
};
+62
View File
@@ -0,0 +1,62 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test } from "vitest";
import {
ErrorCategory,
ErrorCode,
FailToStartLivekitConnection,
MembershipManagerError,
NoMatrix2AuthorizationService,
SFURoomCreationRestrictedError,
} from "./errors";
// These errors take their wording from Element Call's own i18next instance
// rather than the global one, so each needs to come out translated rather than
// as a raw key.
describe("localised errors", () => {
test("MembershipManagerError describes the failure and keeps its cause", () => {
const cause = new Error("the underlying problem");
const error = new MembershipManagerError(cause);
expect(error.code).toBe(ErrorCode.INTERNAL_MEMBERSHIP_MANAGER);
expect(error.category).toBe(ErrorCategory.SYSTEM_FAILURE);
expect(error.localisedTitle).not.toContain("error.");
expect(error.localisedMessage).not.toContain("error.");
expect(error.cause).toBe(cause);
});
test("NoMatrix2AuthorizationService is a configuration problem", () => {
const cause = new Error("404");
const error = new NoMatrix2AuthorizationService(cause);
expect(error.code).toBe(ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE);
expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE);
expect(error.localisedTitle).not.toContain("error.");
expect(error.localisedMessage).not.toContain("error.");
expect(error.cause).toBe(cause);
});
test("FailToStartLivekitConnection passes its detail through", () => {
const error = new FailToStartLivekitConnection("could not publish");
expect(error.code).toBe(ErrorCode.FAILED_TO_START_LIVEKIT);
expect(error.category).toBe(ErrorCategory.NETWORK_CONNECTIVITY);
expect(error.localisedTitle).not.toContain("error.");
expect(error.localisedMessage).toBe("could not publish");
});
test("SFURoomCreationRestrictedError explains the restriction", () => {
const error = new SFURoomCreationRestrictedError();
expect(error.code).toBe(ErrorCode.SFU_ERROR);
expect(error.category).toBe(ErrorCategory.CONFIGURATION_ISSUE);
expect(error.localisedTitle).not.toContain("error.");
expect(error.localisedMessage).not.toContain("error.");
});
});
+22 -23
View File
@@ -5,10 +5,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { t } from "i18next";
import { type ConnectionError } from "livekit-client";
import { i18nKey } from "./i18n";
import { i18n, i18nKey } from "./i18n";
export enum ErrorCode {
/**
@@ -78,10 +77,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
*/
public constructor(domain: string) {
super(
t("error.call_is_not_supported"),
i18n.t("error.call_is_not_supported"),
ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
ErrorCategory.CONFIGURATION_ISSUE,
t("error.matrix_rtc_transport_missing", {
i18n.t("error.matrix_rtc_transport_missing", {
domain,
brand: import.meta.env.VITE_PRODUCT_NAME || "Element Call",
errorCode: ErrorCode.MISSING_MATRIX_RTC_TRANSPORT,
@@ -97,10 +96,10 @@ export class MatrixRTCTransportMissingError extends ElementCallError {
export class ConnectionLostError extends ElementCallError {
public constructor() {
super(
t("error.connection_lost"),
i18n.t("error.connection_lost"),
ErrorCode.CONNECTION_LOST_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
t("error.connection_lost_description"),
i18n.t("error.connection_lost_description"),
);
}
}
@@ -117,10 +116,10 @@ export class MembershipManagerError extends ElementCallError {
*/
public constructor(error: Error) {
super(
t("error.membership_manager"),
i18n.t("error.membership_manager"),
ErrorCode.INTERNAL_MEMBERSHIP_MANAGER,
ErrorCategory.SYSTEM_FAILURE,
t("error.membership_manager_description"),
i18n.t("error.membership_manager_description"),
error,
);
}
@@ -134,10 +133,10 @@ export class MembershipManagerError extends ElementCallError {
export class StickyEventsRequiredError extends ElementCallError {
public constructor() {
super(
t("error.sticky_events_required"),
i18n.t("error.sticky_events_required"),
ErrorCode.STICKY_EVENTS_NOT_SUPPORTED,
ErrorCategory.CONFIGURATION_ISSUE,
t("error.sticky_events_required_description"),
i18n.t("error.sticky_events_required_description"),
);
}
}
@@ -148,10 +147,10 @@ export class StickyEventsRequiredError extends ElementCallError {
export class E2EENotSupportedError extends ElementCallError {
public constructor() {
super(
t("error.e2ee_unsupported"),
i18n.t("error.e2ee_unsupported"),
ErrorCode.E2EE_NOT_SUPPORTED,
ErrorCategory.CLIENT_CONFIGURATION,
t("error.e2ee_unsupported_description"),
i18n.t("error.e2ee_unsupported_description"),
);
}
}
@@ -166,7 +165,7 @@ export class UnknownCallError extends ElementCallError {
*/
public constructor(error: Error) {
super(
t("error.generic"),
i18n.t("error.generic"),
ErrorCode.UNKNOWN_ERROR,
ErrorCategory.UNKNOWN,
undefined,
@@ -186,7 +185,7 @@ export class FailToGetOpenIdToken extends ElementCallError {
*/
public constructor(error: Error) {
super(
t("error.generic"),
i18n.t("error.generic"),
ErrorCode.OPEN_ID_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
undefined,
@@ -203,10 +202,10 @@ export class NoMatrix2AuthorizationService extends ElementCallError {
*/
public constructor(error: Error) {
super(
t("error.generic"),
i18n.t("error.generic"),
ErrorCode.NO_MATRIX_2_AUTHORIZATION_SERVICE,
ErrorCategory.CONFIGURATION_ISSUE,
t("error.no_matrix_2_authorization_service"),
i18n.t("error.no_matrix_2_authorization_service"),
// Properly set it as a cause for a better reporting on sentry
error,
);
@@ -223,7 +222,7 @@ export class FailToStartLivekitConnection extends ElementCallError {
*/
public constructor(e?: string) {
super(
t("error.failed_to_start_livekit"),
i18n.t("error.failed_to_start_livekit"),
ErrorCode.FAILED_TO_START_LIVEKIT,
ErrorCategory.NETWORK_CONNECTIVITY,
e,
@@ -237,10 +236,10 @@ export class FailToStartLivekitConnection extends ElementCallError {
export class InsufficientCapacityError extends ElementCallError {
public constructor() {
super(
t("error.insufficient_capacity"),
i18n.t("error.insufficient_capacity"),
ErrorCode.INSUFFICIENT_CAPACITY_ERROR,
ErrorCategory.UNKNOWN,
t("error.insufficient_capacity_description"),
i18n.t("error.insufficient_capacity_description"),
);
}
}
@@ -252,10 +251,10 @@ export class InsufficientCapacityError extends ElementCallError {
export class SFURoomCreationRestrictedError extends ElementCallError {
public constructor() {
super(
t("error.room_creation_restricted"),
i18n.t("error.room_creation_restricted"),
ErrorCode.SFU_ERROR,
ErrorCategory.CONFIGURATION_ISSUE,
t("error.room_creation_restricted_description"),
i18n.t("error.room_creation_restricted_description"),
);
}
}
@@ -266,7 +265,7 @@ export class SFURoomCreationRestrictedError extends ElementCallError {
export class PeerConnectionTimeoutError extends ElementCallError {
public constructor() {
super(
t("error.peer_connection_timeout"),
i18n.t("error.peer_connection_timeout"),
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
);
@@ -283,7 +282,7 @@ export class PeerConnectionTimeoutError extends ElementCallError {
export class LivekitConnectionError extends ElementCallError {
public constructor(cause: ConnectionError) {
super(
t("error.livekit_connection_error"),
i18n.t("error.livekit_connection_error"),
ErrorCode.SFU_ERROR,
ErrorCategory.NETWORK_CONNECTIVITY,
);
+21
View File
@@ -5,5 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import i18next, { type i18n as I18nInstance } from "i18next";
// Custom marker function to allow i18next extraction
export const i18nKey = (key: string): string => key;
/**
* Element Call's own i18next instance.
*
* We deliberately do not use the global i18next singleton: when Element Call is
* embedded in a host application (rather than running as its own page), that
* singleton belongs to the host, and configuring it would clobber the host's
* translations.
*
* It is configured by `Initializer.initBeforeReact` and made available to
* components via `<I18nextProvider>`; tests and stories configure it directly.
*
* Non-React code should call `i18n.t(...)` on this instance. Components should
* use `useTranslation()` instead, so that they re-render on a language change.
* Note that `t` must be reached through the instance at call time — i18next
* only assigns it during `init()`, so destructuring it at module scope would
* capture an uninitialised function.
*/
export const i18n: I18nInstance = i18next.createInstance();
-1
View File
@@ -40,7 +40,6 @@ export async function initSPA(
try {
const client = await initClient(initClientParams, true);
return {
widgetApi: null,
client,
passwordlessUser,
};
+2
View File
@@ -40,6 +40,7 @@ import { type RaisedHandInfo, type ReactionInfo } from "../reactions";
import { constant } from "../state/Behavior";
import { MatrixRTCMode } from "../config/ConfigOptions";
import { createCallFooterViewModel } from "../components/CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
import { type FooterSnapshot } from "../components/CallFooter";
import { type ViewModel } from "../state/ViewModel";
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel";
@@ -187,6 +188,7 @@ export function getBasicCallViewModelEnvironment(
muteStates,
mediaDevices,
"reactionId",
{ showControls: true, header: HeaderStyle.Standard },
);
return {
vm,
+7 -4
View File
@@ -63,6 +63,7 @@ import { type MediaDevices } from "../state/MediaDevices";
import { type Behavior, constant } from "../state/Behavior";
import { ObservableScope } from "../state/ObservableScope";
import { MuteStates } from "../state/MuteStates";
import { nullHostBridge } from "../HostBridge";
import {
createLocalUserMedia,
type LocalUserMediaViewModel,
@@ -577,10 +578,12 @@ export function mockMuteStates(
joined$: Observable<boolean> = of(true),
): MuteStates {
const observableScope = new ObservableScope();
return new MuteStates(observableScope, mockMediaDevices({}), {
audioEnabled: false,
videoEnabled: false,
});
return new MuteStates(
observableScope,
mockMediaDevices({}),
{ audioEnabled: false, videoEnabled: false },
nullHostBridge,
);
}
export class MockConnection extends Connection {
+4 -2
View File
@@ -7,7 +7,6 @@ Please see LICENSE in the repository root for full details.
import "@formatjs/intl-durationformat/polyfill.js";
import "@formatjs/intl-segmenter/polyfill";
import i18n from "i18next";
import posthog from "posthog-js";
import { initReactI18next } from "react-i18next";
import { afterEach } from "vitest";
@@ -18,8 +17,11 @@ import "@testing-library/jest-dom/vitest";
import EN from "../locales/en/app.json";
import { Config } from "./config/Config";
import { i18n } from "./utils/i18n";
// Bare-minimum i18n config
// Bare-minimum i18n config.
// Unlike the app, tests register the instance as react-i18next's default rather
// than wrapping every render in an <I18nextProvider>.
i18n
.use(initReactI18next)
.init({
+2 -2
View File
@@ -9,7 +9,7 @@ import { describe, expect, vi, it, beforeEach } from "vitest";
import { createRoomWidgetClient, EventType } from "matrix-js-sdk";
import { getUrlParams } from "./UrlParams";
import { initializeWidget, widget } from "./widget";
import { initializeWidget } from "./widget";
import { Config } from "./config/Config";
import { ElementCallReactionEventType } from "./reactions";
@@ -42,7 +42,7 @@ beforeEach(() => {
describe("widget", () => {
it("should create an embedded client with the correct params", () => {
initializeWidget("ANYRTCAPP");
const widget = initializeWidget("ANYRTCAPP");
expect(getUrlParams()).toStrictEqual({
widgetId: "id",
+12 -12
View File
@@ -58,21 +58,21 @@ export interface WidgetHelpers {
}
/**
* A point of access to the widget API, if the app is running as a widget. This
* is initialized with `initializeWidget`. This should happen at the top level because the widget messaging
* needs to be set up ASAP on load to ensure it doesn't miss any requests.
*/
export let widget: WidgetHelpers | null = null;
/**
* Should be called as soon as possible on app start. (In the initilizer before react)
* Connects to the widget API, if Element Call is running as a widget.
*
* Should be called as soon as possible on app start (in the initializer, before
* React), because the widget messaging needs to be set up ASAP on load to
* ensure it doesn't miss any requests.
*
* @returns A point of access to the widget API, or null if Element Call is not
* running as a widget.
*/
// this needs to be a seperate call and cannot be done on import to allow us to spy on methods in here before
// execution.
export const initializeWidget = (
rtcApplication: string = "m.call",
sendRoomEvents = false,
): void => {
): WidgetHelpers | null => {
try {
const {
widgetId,
@@ -202,14 +202,14 @@ export const initializeWidget = (
return client;
};
widget = { api, lazyActions, client: clientPromise() };
return { api, lazyActions, client: clientPromise() };
} else {
if (import.meta.env.MODE !== "test")
logger.info("No widget API available");
widget = null;
return null;
}
} catch (e) {
logger.warn("Continuing without the widget API", e);
widget = null;
return null;
}
};