Files
element-call/docs/agents/architecture.md
T
Timo K.andClaude Fable 5.1 12bb87fa4c Add the MatrixRTC SDK package, its harness and a failing smoke test
Set up `sdk/` as a package shaped exactly like `component/`: its own
package.json with peer dependencies, the pnpm workspace marker so a host can
install it as a git dependency, a build tsconfig for type emission, and a vite
lib config with an externals list that `pnpm lint:externals` now checks for
both library builds.

The package holds only the interface for now. `createRtcSession` returns an
empty object; `RtcSession`, `RtcMember`, `MemberMedia` and `TransportMetadata`
are the contract Element Call's `CallViewModel` will be rebuilt on (see
sdk-plan.md). The shared primitives are re-exported from `src` until the first
implementation slice moves them.

`sdk/dev` is the smallest consumer of the SDK: a plain page that logs in, joins
a room and renders one tile per member, served by `pnpm dev:sdk` on port 3002.
Playwright starts it as a third web server, and `playwright/sdk/smoke.spec.ts`
has two browsers join one session. It fails on the status line with the SDK's
own error until there is an implementation behind the interface.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-01 16:57:34 +02:00

5.2 KiB

Architecture

Logic lives in view models; components render

  • Call logic lives in src/state/. A component that derives call state is misplaced logic.
  • Composing behaviors: a factory taking (scope, ...deps$), returning named behaviors plus callbacks. Copy src/state/LayoutSwitchViewModel.ts. Prefer this for anything new.
  • Owning a resource: a class taking the scope in its constructor — MediaDevices, MuteStates, TileStore, Publisher, Connection. Both styles are current; don't convert one to the other in passing.
  • foo$ is an observable. Behavior<T>, an observable with a current value, is the default for anything a view reads.

The view model / view contract

  • Components take vm: ViewModel<Snapshot> and read state through useBehavior. See CallFooter, InCallView, LobbyView, SettingsModal.
  • A snapshot is Actions & State; every field becomes a foo$ behavior, and none is optional.
  • An unavailable action is undefined, not a separate canDoThing flag — the presence of the callback drives the rendering.
  • Subscribe in an effect only to drive a side effect off an event stream, as ReactionAudioRenderer does; never to read state a behavior already holds.

Scopes own lifetimes

  • ObservableScope bounds every subscription a view model creates.
  • Reference only the scope defined in the same function; one captured from an enclosing scope outlives its owner, and no-observablescope-leak rejects it.

Nothing reads the page

Element Call can be mounted several times inside a host's React tree, so it owns neither window, URL, document nor router. Each seam defaults to the old standalone and widget behaviour.

Never Use
the widget global useHostBridge() — src/HostBridge.ts
getUrlParams(), window.location useUrlParams()
useNavigate("/"), <Link to="/"> useLeaveToHome(), LeaveToHomeLink
document.body useRootElement()
window.innerWidth/Height, useMediaQuery useRootSizeMatches() in views, windowSize$ in view models
global i18next the instance in src/utils/i18n.ts
config or analytics reading their environment Config.initWith(), PosthogAnalytics.configure()
  • View models take values as options, never getUrlParams() (callViewModelOptionsFromParams, CallViewModelOptions.hostBridge).
  • The host bridge is the only channel to the host: createWidgetHostBridge(widget), nullHostBridge standalone, useComponentHostBridge. State a capability (supportsReactions, supportsProfileChanges); never infer it from being a widget.
  • Shortcuts and portals attach to the root element, so two instances don't fight.
  • Known debt, not precedent: Grid measures window.innerHeight; ErrorView's reload and getAbsoluteRoomUrl use window.location; recaptcha appends to document.body on the standalone login path.

Context differences are options, not checks

  • Named options with per-intent defaults (configurationForIntent), never a runtime check for who is hosting.
  • controlledAudioDevices, not the platform, is what makes MediaDevices pick AndroidControlledAudioOutput / IOSControlledAudioOutput over the web AudioOutput. Intent presets set it on non-desktop; standalone leaves it off.
  • URL params are a published contract: change one, update docs/url_params.md.

Build targets

Code that builds in only one is a bug.

  • build:full — standalone app, also widget mode.
  • build:embedded — @element-hq/element-call-embedded.
  • build:sdk — @element-hq/matrixrtc-sdk, sources in sdk/, entry sdk/index.ts. Packaged exactly like the component (own pnpm project, run pnpm from the repo root, types from sdk/tsconfig.build.json, externals checked by pnpm lint:externals). Interface only so far; the design is sdk-plan.md.
  • build:sdk-target-based-on-call-view-model — the SDK demo that predates the package, one bundle of the CallViewModel; entry sdk-target-based-on-call-view-model/main.ts.
  • build:component — @element-hq/element-call-component, sources in component/ (its own pnpm project; run pnpm from the repo root). Host API is in the README; pnpm lint:externals rejects an import of a react / react-dom / matrix-js-sdk / livekit-client / @vector-im/compound-web / @vector-im/compound-design-tokens subpath the externals list omits. Compound's stylesheets are loaded by src/index.css for the page and by the host for the component, never by src/base.css. Two entry points: index.tsx (the component) and api.ts (types, enums and configurationForIntent, for a host that must not load the component). api.ts may only reach types and constants — src/UrlConfiguration.ts, not UrlParams.