De-globalize Element call - #4225
Draft
BillCarsonFr wants to merge 18 commits into
Draft
Conversation
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Important
Features and UI changes require a pre-approved issue.
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 for ElementCall decided for this approach.
Content
Preliminary work that would allow Element Call to be embedded in different "shells", be it an iFrame as a widget, in a standalone app, or as a react component in another app.
This preliminary work is removing all global access EC is doing, and replacing them with a proper injection.
Motivation and context
Screenshots / GIFs
Tests
Checklist