- At four the grid stopped at eight tiles, since the add tile goes at the
limit, and left its last corner empty. At five it is nine at the limit,
and four with the add tile is nine one short of it.
- Guarded as the rule rather than the number: the grid is whole at the
limit and one short of it. Fails at four.
- The stylesheet had claimed a whole three-by-three at the limit all along;
now it is one.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Added to the backgrounds on offer and in force once chosen, in the lobby
exactly as in a call. Choosing a file and choosing to wear it are two
decisions, and one rule for both places is one less thing to know.
- The footer's before-joining flag existed only for the other rule, so it
goes, from both view models and the story.
- The edge-fade story waits longer for a scroll to settle: under the full
parallel run a wait could outlast the default second.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The platform fades its scrollbar out after a moment, and with a trackpad
on a Mac there was then nothing to say the list scrolled: the backgrounds
seemed simply to stop. Keeping the scrollbar would mean drawing our own,
overriding a setting the user chose, and still not working in Firefox.
- Instead a fade at each edge of the list while there is more that way, as
iOS, Android and Material do: the rows and tiles fade into it at the foot,
and at the top it stands just below whichever heading is stuck there,
where the content comes out from under it.
- Worked out from the list on scrolling, on resizing and after every render,
since adding or removing a background changes its length without either.
- The camera menu only: in the microphone menu the level meter holds the
foot of the list.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The effect tiles are positioned, to carry their tick and the remove
cross, and positioned content painted after a sticky heading slid over
it: the grid ran across "Background effects" instead of under it. The
headings now have a layer of their own.
- Where the call is too short to pin the preview, it is no longer dropped:
it goes first into the list and scrolls away with the devices and the
effects, seen on opening and out of the way once choosing.
- The list moves up over the menu's padding to bring it flush with the
top, since a scrolling container cuts off what is pulled above it, and
rounds its top corners with the frame's so a stuck heading cannot show
past the curve.
- Guarded: the heading is what is on top where it stands, failing without
the fix; and the preview sits inside the list and scrolls away.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Once the device list had enough in it to scroll, Safari painted it above
the outline the menu is framed with, and the frame vanished beside every
device. Not new: the microphone menu with many devices lost 384px of it.
The camera menu only met it because the self-preview shortens its list.
- The list now holds itself a border width clear of the frame, so nothing
inside it has to: the headings and the meter lose their own clearance.
- Frame intact in both engines in every case measured. Belongs to the
device menu, so it can be cherry-picked on its own.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A long device name now wraps instead of widening the menu over more of
the picture the user is judging. Narrower only where the call area is,
so it still fits inside it: 268px in a 300px call.
- The self-preview is always the sixteen by nine it is drawn at, 294 by
166 inside the frame, and Safari's narrowed preview has nothing left to
happen to.
- Tiles are thirds of the set width, about the 80px design draws, where
their floor was there only to push the menu wider.
- Nothing else in the menu gets to set its width either: the picture
sits over its box, and the notice and the refusal fit the menu rather
than asking for their sentence on one line, which took it to 340px.
- The microphone menu is unchanged.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A ratio with a height cap let Safari keep the ratio by narrowing the box:
once a long device name widened the menu past 313px, the preview stopped
short and left a strip of menu at its right. Chromium stretched it.
- A fixed height instead, cropped by the picture: sixteen by nine at the
menu's narrowest, wider as the menu widens, the same in both engines.
Measured in WebKit before and after, 313px against a 398px menu, then
398px.
- Guarded in a wide menu, though in Chromium only: no test tier here runs
WebKit, so the story holds the requirement rather than the engine.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A live self-preview at the top of the camera menu, as design drew it:
full width, flush with the top, sixteen by nine. Both sections scroll
beneath it, because it is what the choosing below is for.
- Only where the call leaves room. It is paid for out of the device list's
share rather than on top of it, and only where the list keeps its floor
after paying, so the menu is never taller for having one.
- Attached to the track the pipeline is synced to, which both the lobby and
the call already report, so it shows the effect in force without a capture
or a pipeline of its own. Mirrored like the pre-join preview, a camera-off
mark while there is no camera, and the wait while the first effect builds.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- The menu carries bottom padding of its own, so setting one on the notice
as well made the gap under it twice the gap beside it.
- Guarded as the relationship rather than the number, which is what anyone
looking at it would notice. Fails at 32 against 16 without the fix.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The design's second pass: the subtle ground, an outline mark and text at
its ordinary weight, sixteen pixels in from the menu's sides like the
grid above it. Measured against the drawing, the ground and the inset
land on it exactly, and the padding gives the same box for the same
number of lines.
- No longer an alert. An alert is for something that just happened; this
was true before the menu opened and will be true after it closes, which
is also why it cannot be dismissed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Reads each shipped file and requires a format with no alpha channel in it.
Fails on the overlay scrims that started this, which is the point.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The stand-in backgrounds were the app's own gradients, which are overlay
scrims: measured, not one pixel in either is opaque, and where they look
dark they are transparent. As a background their dark half was simply
absent, which is what showed on the slow path.
- They are now laid on the canvas colour they are painted over and kept as
JPEG, which cannot carry transparency at all.
- The same hole was waiting for anyone's own picture: a canvas starts
transparent, WebP keeps an alpha channel, and a file small enough to keep
as it was never passed through either. Every picture now goes through an
opaque ground, whatever its size.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- A docs page putting the four states side by side: offered, offered with
what it costs, being set up, and not possible at all.
- The settling story plays the sequence rather than posing it, shortened so
it is watchable.
- The notices carry the real strings, so a reviewer reads the words a user
would rather than a paraphrase.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Drops the desktop test. Measured, it sorted devices by the wrong thing:
a phone on the fast path held 99% of its frame rate and was refused, a
desktop on the slow one held 73% and was allowed.
- What the cost tracks is the processing path, and Safari and Firefox take
the slow one on every platform. That is told, not enforced: the cost is
smoothness, the reason to pay it is privacy, and only the user knows
whether they would rather show the room they are sitting in.
- The first effect of a session takes a while to prepare, so its own tile
spins where its tick will go, until a frame has actually come out of the
pipeline. The promises say nothing useful about when that is: attaching
resolves in about three seconds and switching in none at all, and the
slow path then holds the page still for twelve to fifteen more. The
spinner turns by rotating, which a browser composites, so it keeps moving
through a pause that has stopped everything else.
- Copy is the exploration's and needs an owner (S3).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Splits attaching from switching: building the segmenter fetches, compiles
and sets up the GPU, while switching only tells a built one what to draw.
One of those can be paid ahead of time.
- Reports the segmenter's assets as the browser saw them. A transfer size
of zero means cache, which is what separates the first run on a device
from every run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- They closed only when a frame arrived, so a freeze could not be reported
as one: the iPhone's fourteen frozen seconds came back as a single
bucket at a seventh of a frame per second.
- Adds the two numbers that say it plainly: time to the first frame, and
the longest the picture simply stopped.
- A run now ends even if no further frame ever arrives.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The camera and the pipeline, no call in the way: if a device cannot hold
a frame rate here it cannot hold one in a call either.
- Reports mean and worst five-second frame rate, startup, and the worst
stall on the main thread. Run no effect first; every number is only
meaningful beside that device's own baseline.
- Ignores the desktop gate on purpose. Measuring what it forbids is the
point, and the gate was written from reports rather than numbers.
- Exploration only, on an unlisted route, and one path segment deep: the
config is fetched relative to the page, so a nested path never starts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The menu asked the browser for the APIs; the pipeline asked for those and
a desktop. A phone browser sat between the two: every effect offered,
none applied, nothing said.
- Both now ask the same function, so what is offered and what can be
delivered cannot drift apart again.
- Tested against the lobby, where the switcher stays enabled on a phone —
in a call it is already withheld there, so a check would pass whatever
the verdict said. The test fails on the old wiring.
- This also fixes the blur toggle, which had the same split.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Critical from the moment it appears, not only under the pointer: the
ground carries the hover instead, and gains a ring where the shadow was.
- 28px on the tile's corner, overhanging it by a fifth of itself, with the
glyph a third of the button as the mock draws it.
- It names itself: a Remove tooltip, so the cross has words.
- Every colour is a Compound token and lands on the mock's own value in
both themes, bar the dark hover ground, which is one step up the red
ramp from what the mock measures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- oxfmt over the six files the exploration added or changed.
- Wrapping only, no behaviour: the formatter is a separate CI step from
pnpm lint, which is why it went unnoticed on the branch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The camera menu drew its own rule under each heading, because the menu
suppressed Compound's. It no longer does, so ours drew a second one:
the separator, its class and the heading override all go.
- The grid is its section's first control, so it starts under its rule
where a device row's control starts under that one.
- Guarded in the story: two headings, each ruled edge to edge, nothing
else dividing them, and both sections' first controls level.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Measured off the design file: its gap is about a sixth of a tile's
width, ours was under a tenth
- A step wider brings it to 14.3% against the design's 15.6%, the closest
a spacing token reaches
- The tiles keep their width and the menu grows instead, which is the
column floor doing its job; the grid still sits 16px from the frame
- Gives the remove cross on the corner room of its own, which is what
prompted it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- On the top-right corner, where Zoom and Teams both put it
- Centred, it sat inside the target for choosing the tile: the one
destructive action lived exactly where the ordinary one does, which is
what was confusing people
- Red under the pointer. That alone would not have fixed it: colour says
what a control does, it does not stop the press landing on the wrong one
- The one in force still offers no removal (FR-025). The corner would
allow it, since cross and tick no longer compete for the middle, but
that stays product's call
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Before joining nobody sees the change, so the picture goes on at once
- In a call it waits to be chosen: otherwise choosing a file would change
what everyone sees with no further word from the user
- Teams draws the line in the same place, which is what settled it: a
background added before joining applies by itself there, one added in a
meeting has to be applied
- The footer learns which side of joining it is on from its view model,
since the lobby and the call build their own
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Whether choosing a file should also wear it is still open, so this does
the smaller thing: the image joins the others and the user says when
- Reverts the auto-selection added earlier, which was never proven: the
stories carry selection as a fixed arg, so nothing there could show it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- It sat inside the device list, which scrolls and is bounded by the size
of the call, so the menu's own height cut it off
- Now below that list, where nothing can clip it or leave it under the
fold: no way to tell someone something failed
- 16px from the menu on every side, a step of which the menu's own column
gap supplies
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- It sat flush against the tiles with twice that gap beneath; now 16px
all round, the same as everything else in the menu keeps from the frame
- It can be dismissed, and it does not outlive the menu it appeared in.
Before, it stayed until the next attempt: closing the chevron and
opening it again showed a message about a file chosen long ago
- Checked: shown on refusal, gone when dismissed, shown again on the next
refusal, gone on reopening
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The reason appears with the grid, where the user chose the file, using
the same Alert the reaction menu already reports errors with
- A reason for each: not an image, animated, could not be opened, and a
fallback for anything the store itself refuses
- Named one by one rather than built from the reason, so the extractor
finds every string it has to translate
- The background in force is untouched, which is the other half of
FR-021: being refused a file changes nothing
- Covered by a story, and checked end to end by offering a text file:
the message appears, the menu stays open, nothing is kept
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The cross now sits beside the tile rather than inside it. Inside, the
menu reached the item before any handler on the cross, whichever phase
it was in: the tile was chosen, became the one in force, and the cross
was taken away before it had acted
- That is why the keyboard worked and the mouse did not, and why the
static stories missed it: with selection fixed by an arg, nothing
re-rendered and the click survived
- A story with live selection covers it now: the cross removes the tile
and does not make it the one in force on the way out
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- A cross where the tick would be, on hovering one of their own images
- Not offered on the one in force: that is what they are wearing, and
taking it away would leave them with nothing chosen (FR-025)
- Delete or Backspace on a focused tile does the same, announced through
aria-keyshortcuts. The cross is drawn rather than focusable: a control
inside a menu item is invalid, and the item is what the arrows walk
- Covered by a story: the one in force offers no removal, the others say
Delete and answer to it
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The grid now stands the same distance from the menu's frame on every
side: 16px left, right, bottom, and above from its rule. It was 16 at
the sides but 12 above and 28 below, since its own bottom padding and
the menu's were both in play
- A ruled heading sits closer to its rule than the menu's own spacing
allows, which is set for a heading with nothing under it: 14.5px from
the heading's text rather than 18.5px, and the same in both sections
- Scoped to headings that are ruled off, so the microphone menu keeps the
spacing it was drawn with
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Thinner: colour alone, at the width every tile already carries
- The same on an added background as on no effect. It was thickened with
an inset shadow, which draws inside the padding box where an image
tile's picture sits, so the picture covered it and image tiles wore a
thinner frame than the rest
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The tile grid added its own room on top of the rule's, so the tiles sat
20px below their rule while the device rows sat 12px below theirs
- The rule sets that distance now, and it is the same in both sections
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- A rule under "Camera" as well as under "Background effects"
- Both run edge to edge: a separator insets itself by default, which
leaves it floating short of the menu's sides
- The divider that separated the two groups goes; the rules do that work
- Closer to what they name than a separator's own spacing allows, which
is set for one dividing two sections
- Scoped to the camera menu: the microphone menu still divides its two
sections instead, which is the design the audio menu was drawn to
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- The picker moved into the menu: a native file dialog takes the focus,
which the menu read as a click elsewhere and closed behind it
- Close requests are ignored while the picker is up, and a cancelled
pick releases that through the input's own `cancel` event, which React
does not type
- Adding answers with the background it kept, so the footer can choose
it straight away rather than asking the user to pick it twice
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Added backgrounds are kept on the device in IndexedDB; nothing uploads
them and no identifier would let anything fetch one
- Refuses what cannot be used and reduces what is larger than we keep:
a 4000x3000 photo became 1920x1440 and 238KB became 5KB
- Animation is read from the file, not guessed from its type: an
animated WebP and a still one share a type, and a .png may be an APNG
- Does not crop. The pipeline already covers the camera's frame with
whatever it is given, recomputed as the frame changes, so cropping on
the way in would bake in one shape and lose the rest for good
- The add tile is withheld once four are kept, rather than failing
- Added backgrounds live in their own context, not in ProcessorState:
the publisher has no use for them and every stub of it would carry them
Not done: removing an added background, which the design gives no
affordance for, and telling the user why a file was refused, which has no
surface in the menu yet. Both are logged for now.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Both now read and write one setting, so they cannot disagree
- An image background reads as blur off there; turning blur on replaces
the image, which is what FR-022 and EC-011 ask for
- The new setting defaults to whatever blur the user already had, so
nobody loses the blur they turned on before this existed
- Covers the derivation with tests: what the checkbox shows, what it
writes, and the fallback when a stored background is one we no longer
ship
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- nodePolyfills rewrote MediaPipe's WASM loader, which mentions `process`
- That loader is a classic script whose job is to set self.ModuleFactory;
as an ES module it sets nothing, so the segmenter threw
"ModuleFactory not set" and no effect could run under `pnpm dev`
- Its transform now skips that one directory
- Affects blur on main too, not only this feature: neither could be
exercised locally before
- Production was never affected, since the loader is emitted as an asset
there; build still green and the assets still emitted
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Footer view model exposes the chosen effect and a setter for it
- The footer names the catalogue: a view model holds no translated text
- The camera menu shows the grid in place of the blur toggle
- Shipped images are numbered, not named, since the art is a stand-in
- Withholding the setter is what renders the section disabled, as with
the speaker section where no output can be chosen
Exploration for FEATURES_SPEC/2026-09_Background_Effects.md.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the Background effects section from the design: no effect, blur, the
shipped images and a tile for adding your own, under the camera list.
The tiles are menuitemradio like the device rows above rather than a new
control, so the menu keeps one keyboard model throughout and the selection is
announced rather than only drawn. An image tile is the picture, filling the
tile, and its name goes to assistive technology alone; the add tile is a plus,
named the same way. The tick stands where the glyph would, carrying its own
ground on a picture so it reads against whatever is behind it.
Repurposing a menu item as a tile means overriding its row layout on three
axes, each found by measuring rather than by eye: it lays itself out as a grid
of chevron, icon and label, so flex-direction does nothing; it carries the gap
between them as a right margin on each, which pushes both off the tile's
centre when they are stacked; and it insets its own text sixteen pixels, so
the grid needs the same inset to line its tiles up with the heading above them.
Sized against the design file rather than judged: glyph and tick a quarter of
the tile wide, an ink gap of about a seventh of its height to the label, and
the tile's proportion carried by its padding. Rows are equal height, set by
the tallest tile — a fixed ratio clipped the label as soon as the menu was
narrow enough that the ratio could not hold it — and the columns carry a
minimum width so the menu grows wide enough for three tiles rather than
shrinking them to whatever the device names need.
The blur hatch is a stand-in: the design system has no blur icon, and the
design's own is an icon whose strokes shorten towards the corners. A uniform
hatch fills its box, so this one is drawn smaller and finer to carry the same
weight rather than the same size.
Where background processing is unavailable, no effect stays choosable: it
needs none and it is what the user has. The rest are disabled, and the images
lose their colour as well as their contrast, since a washed-out photograph
still reads as one you could pick.
The existing blur toggle stays for callers that pass no effects, so nothing
that renders this component has to change yet.
Exploration for FEATURES_SPEC/2026-09_Background_Effects.md. Not for merge: the
image tiles are stand-in gradients drawn in the story, and nothing wires the
grid to the pipeline yet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replace the blur-only processor with a single wrapper that switches between no
effect, blur and a background image in place. Switching in place rather than
rebuilding matters: destroy() resets the transformer's first-frame flag, and
that flag makes it emit one unprocessed frame when it next starts processing,
so a rebuild per change would leak a frame of the real background every time.
Attach the pipeline the first time an effect is chosen and keep it attached
afterwards, including at no effect. Measured against a production build, the
segmentation assets are about 11.4MB and take ~143ms to initialise, so always
attaching would charge that to every user on every camera-on call, including
the majority who never choose an effect. Keeping it attached once it is there
costs about 25us per frame, which is within noise of no pipeline at all.
Complete init() while moving it. The old subclass replaced the base class's
init wholesale and dropped its image loading and disabled-mode setup, which
went unnoticed while blur was the only effect and blurRadius was the only
option that mattered.
Keep loading the segmenter from our own bundle. The library's assetPaths option
resolves WASM through MediaPipe's FilesetResolver from a directory, which is
not the same thing, and its defaults reach jsDelivr and googleapis.
Exploration for FEATURES_SPEC/2026-09_Background_Effects.md. Not for merge:
the two shipped backgrounds are stand-in gradients and there is no picker yet,
recorded as S1 and S2 in the spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- main stopped labelling the virtual default output with the first device's
name (426acd22): a pinned sink is not re-routed, and which device the browser
default resolves to is not knowable from here.
- The exploration still carried the older file, so this check passed locally
and failed in CI, which tests the branch merged with main.
- AC8 is unchanged — Default is still a distinct entry beside the device it
resolves to. Only the label it carries has moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Each section is headed by its own rule, edge to edge, instead of one inset
divider between the sections. Compound draws that rule under a menu heading
already, so this removes our Separator, its spacing rule and its import.
- Our heading padding override goes too: Compound's menu heading is the design
already — 13px/600 on a 19.5px line, #656D77, 16px either side, 7px from the
text to the rule. Ours had been moving that 7px above the text.
- Two spacings do differ from Compound, both measured off the mock rather than
guessed: 20px below the rule before a section's first device (27px rule to
control, against Compound's 16px), and 28px above a heading that follows
another section (41px from the last control to the next heading's text,
against 22px). The first heading keeps Compound's spacing — the menu's own
padding sits above it.
- Guarded by relationships, not numbers: the first control sits further below
the rule than from the menu's edge, and a section stands further from the one
above it than a heading does from its own first device. Both fail if either
spacing goes back to Compound's 8px.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Covers AC28, which `ux_design_shevron.png` settles.
- Not written in this session: it was uncommitted in the tree beforehand, and
is committed here so it is not lost.
- The stories supply what the app supplies rather than what a harness has
lying about: a microphone, and a call-sized root element.
- Invented device ids meant the menu asked for hardware that does not exist,
so the meter reported no microphone. A tone played into a real MediaStream
fixes it, and the meter runs its own analyser over it.
- Without a root element the list was bounded by Storybook's whole frame and
the menu ran off the canvas; without a call-sized one, four devices scrolled.
- Standalone specs: the switch that does not drop the call, the meter alive
while muted, the pinned meter, the focus ring.
- Component specs: the list sized to the call and not the window, resized
while open, every device reachable, and the modality belonging to one call.
- A headless browser has one microphone and one speaker, so fake-devices.ts
adds synthetic ones. It is explicit that it does not route audio.
- Both arms of the switch were the same eleven lines of click, disabled, busy
and enabled wiring; only the component and the test id differed.
- Two copies that had to be kept in sync by hand, for nothing.
- `toggles` becomes an expression rather than a `let` mutated in a switch,
which makes the optional chaining downstream dead.
- The labels switch stays: i18n extraction needs literal `t("…")` keys, and it
gives exhaustiveness checking on the union.
- Was watched at the document, so a key pressed in another Element Call on the
same page — or in the host's own page — lit this menu's ring. Element Call
can be mounted more than once, and the menu is portalled out of the root.
- Watched on the menu instead, which is the element that holds focus.
- A callback ref, not an effect on `menuOpen`: that state is ours and the open
menu is Radix's, and they do not commit together, so the effect could run
before there was anything to attach to.
- Recorded on the menu rather than in state, so every item it can focus answers
to it — the camera menu's blur toggle kept the browser's own ring otherwise,
which follows the pointer.
- Nothing re-renders when the modality changes now, so the reset folds in with
the listeners and the second effect on that state goes.
- Comments moved to the code they describe; three unrelated ones had stacked up
on one declaration.
- The heading of the section you are reading stays at the top of the list and
leaves with its own section, so a long list never leaves you guessing which
kind of device a row is.
- Opaque and held a border width clear of the sides, like the meter: a
positioned element paints over the outline the menu draws its frame with.
- Sticky alone hides rows: scrolling a row flush to either edge puts it under
the heading or under the meter, which is how a row reached by keyboard ends
up half-readable. scroll-padding-block keeps both heights clear.
- Measured from the real elements, not from tokens — the meter's failure states
are two lines where a level is one.
- The bound was taken once when the menu opened, so a host resizing the space
Element Call is drawn in while the menu is open left it describing a call
area that no longer exists.
- Follow it with observeElementSize$, which was already here and already used
by useRootSizeMatches, and only while the menu is open.
- Quantised before it reaches React, so a drag-resize re-renders only when the
bound itself moves.
- 160 and 0.6 are named now, with the reason each exists.
- Safari enumerates no output devices at all, so the list arrived empty — and
an empty array is truthy, so the Speaker heading rendered with nothing under
it, which reads as a broken feature.
- Show one entry instead, named as the default, disabled, and marked as the
selection: audio is going somewhere, and an unchecked lone row reads as
nothing being chosen.
- D2 already says a row that cannot be chosen is shown disabled, never hidden.
- Covered by its condition rather than by the browser, so Linux CI checks it:
one test that the view model hands over an empty list rather than none, one
that the menu then draws the default.
- Measured: the icon's centre sat 3px right of the radio controls above it.
- Two causes, both off the padding: the row is inset a border width to keep
the menu's frame visible, and the icon is wider than a radio control.
- Vertically the icon was right and the text was not — a paragraph's bottom
margin means the margin box gets centred, lifting the words.
- Guarded in a story, not a unit test: jsdom lays nothing out.
- A bar and the gap beside it are fixed; how many bars there are follows the
space. Spread gaps made the meter a different shape everywhere, and closed
the bars into one block when space ran short.
- Bars that touch cannot be counted, and the count is what carries the level
without colour.
- `contain: inline-size` and `min-inline-size: 0` so the width decides the
count and not the reverse — without them the bars set the menu's width.
- Gap is half again the bar width, measured off the design mock: 10px bars
14px apart, a ratio of 1.4, nearest token pair 4px and 6px.
- Sizes stay in the stylesheet; the component reads them back off a rendered
bar rather than repeating them.
- The capture, the analyser loop and the loudness maths were in a component
hook. Call logic belongs in the state layer; components render.
- observeMicrophoneState$ opens the device when something subscribes and
releases it when nothing does. The hook is left as a bridge, no domain logic.
- Fixes a leak. A permission prompt outlives the menu that opened it, so the
browser could hand over a microphone after cleanup had already run — with the
variable that cleanup would have released still unset. The device stayed
held, indicator lit, no meter on screen.
- Expressed now as the subscriber being closed, which the observable can always
answer.
- The hook had no tests at all, which is why this survived. Four now, and the
capture stub they need is in the shared factories rather than hand-rolled.
- One counts emissions across twenty animation frames and expects one: the
redraw cost AGENTS.md asks continuous drawing to account for.
- Show the focus ring only when the keyboard moved the focus. Radix focuses
whatever the pointer is over, so the browser cannot answer that question
here: Chromium treats every focus after a key press as keyboard-driven,
Firefox treats no programmatic focus as keyboard-driven. The menu records
which modality arrived, watched at the document while it is open, since the
first arrow key lands on the menu itself rather than on anything we render.
- Suppress the browser's own ring on menu items, so there is one answer to
that question rather than two.
- Drop readOnly from the device radios. Compound paints a read-only control
muted, and that rule comes after the checked rule, so it overrode the accent
fill marking the selection and the menu stopped matching settings.
- Make the decorative radio inert rather than aria-hidden. A negative tabindex
inside an interactive control stays reachable to assistive technology even
when hidden, which axe rejects.
- Give each section a labelled group and mark its heading decorative. A menu
may contain only items, separators and groups, and the headings were direct
children of it. The list wrappers are role="none" for the same reason.
- Run axe over the open menu, which is what found both of the above.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC23, AC24, AC25
- Bound the scrolling device list by the measured height of the call area.
It was capped with a viewport unit and a Radix variable, both of which
measure the window: as a component in a corner of a host's page, the menu
would have been sized against the whole page. The menu is portalled outside
the root, so no container query reaches it and it has to be measured.
- Leave a single divider between the two lists, as the design has. Compound
underlines every menu heading, so the headings drop theirs, and the line
closing the microphone section goes: the section wrapper already bounds the
meter's stickiness without one.
- Cover device persistence, hot-plug, fallback when the device in use is
removed, fallback to a default when a remembered device is gone, numbered
labels before permission, "Default" listed as its own entry, one device kind
not disturbing another, and camera parity. Their acceptance criteria named
check commands for tests that did not exist, which makes the criteria
unenforceable and blocks the next drift check.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC3, AC4, AC5, AC8, AC9,
AC10, AC11, AC19, AC20
- Draw the level beneath the microphone list as 24 segments, announced
through role="meter", so it reads by count and not by hue alone.
- Give denied permission and no input device their own message and next
action, rather than a flat meter that reads as silence.
- Read the selected device, and only while the menu is open, so nothing holds
a second capture for the length of a call.
- Follow a rise quickly and a fall slowly, in elapsed time rather than frames,
so the meter does not chase the gaps between syllables.
- Ignore a noise floor, below which a quiet room's hiss would light the first
segments permanently. Smoothing changes how the meter moves, not where it
settles, so it does not replace this.
- Scroll only the device lists, and keep the meter pinned to the foot of the
microphone section while its rows are in view.
- Withhold the output select callback where the platform cannot route audio
to a chosen device, which is what renders the speaker section disabled.
- Track the in-flight device by kind as well as id: Chrome names both an
input and an output "default", so the spinner lit the wrong row.
- Disable every device while a selection is settling, so a second request
cannot overtake the first.
Two CSS choices are load-bearing and look arbitrary: the meter's wrapper is
unpositioned, because a positioned one paints above the menu's outline and
swallows the frame along that section, and the meter is held clear of that
outline, because it is the only opaque thing in the menu.
The meter opens its own short-lived capture rather than tapping the call's
audio track. That lets it follow the picker instantly and avoids the pre-join
track, which is frozen to the device selected when the screen mounted. The
cost is a second capture while the menu is open. See the notes sidecar.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC11, AC13,
AC14, AC15, AC17, AC18, AC21, AC22
- Draw the input level beneath the microphone list as 32 discrete segments,
so the level reads by count and not by hue alone.
- Expose it through role="meter" with aria-valuenow and aria-valuetext, so a
screen reader announces it.
- Give denied permission and no input device their own message and next
action, rather than a flat meter that reads as silence.
- Read the level only while the menu is open, so nothing holds a second
capture of the microphone for the length of a call.
- Read the device the picker has selected, so the meter re-points as soon as
the selection changes.
- Ignore anything below a noise floor: a quiet room is never digitally
silent, and its hiss otherwise lights the first segments permanently, which
reads as "it can hear me" when nobody is speaking.
- Quantise loudness with a square root above that floor: amplitude puts
ordinary speech near the bottom of the range, where a linear meter barely
moves.
- Withhold the output select callback where the platform cannot route audio
to a chosen device, which is what renders the speaker section disabled.
Safari and most Firefox builds land here.
- Track the in-flight device by kind as well as id: Chrome names both an
input and an output "default", so the spinner appeared on the wrong row.
- Disable every device in the menu while a selection is settling, so a second
request cannot overtake the first.
The meter opens its own short-lived capture rather than tapping the call's
audio track. That is what lets it follow the picker instantly, and it avoids
the pre-join track, which is deliberately frozen to the device selected when
the screen mounted. The cost is a second capture of the same device while the
menu is open. See the spec's notes sidecar.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC13, AC14,
AC15, AC17, AC18, AC21, AC22
- Mark the selection with a radio control instead of a trailing check icon
beside a device glyph, matching the design and the settings modal, so both
device pickers read the same way.
- Take the visual from Compound's RadioInput rather than restyling a span of
our own. RadioControl is wrapped in a Radix form control and needs a Form
ancestor, which a dropdown menu has no business providing; RadioInput does
not.
- Keep rows as menuitemradio with aria-checked, rendered through
MenuItem as="div" so an input is never nested inside a button.
- Render the radio aria-hidden and not focusable: Radix owns focus inside the
menu, and aria-checked carries the state.
- Leave the activating spinner unchanged.
- Keep every scope.behavior call inside the function that owns the scope,
which no-observablescope-leak requires.
- Cover the speaker section, an output that cannot be chosen, and a lone
device shown disabled, with unit tests and stories for each.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC7
The microphone chevron listed input devices only, so changing the output
device meant leaving the call controls for the settings modal.
Render the audio menu as two sections, Speaker above Microphone, and wire
the output device list, selection and select callback through the footer
view model. Entries are shown but disabled where the platform cannot switch
that kind of device, or where only one exists, so the menu keeps the same
shape everywhere.
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1, AC6, AC7, AC8
LiveKit's livekit-ice logger records each local ICE candidate, ICE
candidate errors and ICE connection state changes, but only at debug,
which we cap to info unless extended LiveKit logs are switched on. That
means a 'could not establish pc connection' rageshake carries no way to
tell 'no relay candidates at all' from 'relay candidates that never
paired'. Keep that one logger at debug regardless of the toggle.
* Add AGENTS.md documenting the repo's conventions
Codifies what is currently tribal knowledge or only discoverable by reading CI
config: the view model / view contract, the rule that nothing reads the page,
Clean Code ordering, the three test layers, and reuse-before-you-build.
The root AGENTS.md is the always-loaded minimum and routes to docs/agents/, so
an agent loads only the detail its task needs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Allow either hand at the commit, and measure continuous redraws
Committing from the agent rules out running it where there is no git
identity, a sandbox included. Either is now fine: commit on the user's
word where the setup allows it, or write the message and hand it over.
Redrawing continuously is the other addition. A level meter reported a
fresh value every animation frame, so its whole menu reconciled sixty
times a second, silence included, and nobody noticed until review. A PR
that adds anything of the kind now says what it costs, or better, counts
the redraws — commits rather than render calls, since React runs a
component it then discards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
The "has no matching matrix call member" warning fired on every re-render
(i.e. every active speaker update) and also for the local participant's own
track, which is never rendered here anyway. Skip local tracks and warn once
per unexpected identity.
On browsers without a "default" pseudo-device (Firefox, Safari), the
virtual default output entry was appended after the physical devices, so
with no saved preference EC selected the first physical device and pinned
every remote audio element to it with setSinkId. Pinned sinks are not
re-routed by the browser: on Firefox/Linux a Bluetooth headset switching
from A2DP to HFP when its microphone is opened (i.e. on unmute) destroys
the pinned sink and all remote audio goes silent, with no error and no
fallback (rageshake 17320).
List the virtual default first so it is the fallback both when nothing was
chosen and when the chosen output disappears, and stop labelling it with
the first device's name since the browser default is not necessarily that
device.
IMO this is where the delegation calls should have lived all along, since the leave event is part of the membership lifecycle, and we otherwise end up with an awkward hack to ignore transport updates.
Doing this now ensures that the client won't send any delegation requests if delegation is unsupported, and prepares the code for a future change in which we use the dedicated delegation endpoint from the CS API.
Since we removed the extra 'legacy' mode, meaning there's nothing simplified anymore by translating things over to a JWT service version.
(Also, simplify some things by making the mode static for the duration of a call.)
`downloadMedia` exists because a widget's client cannot fetch media: it
has no access token, and its only route to a file is MSC4039 over the
widget API. A component host hands over a client that does hold
credentials, so Element Call fetches media with it and the callback was
never needed there. It comes off `ElementCallHostBridge`, leaving a host
with only what it must understand, and stays on the internal HostBridge
for the widget.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The theme is state — what Element Call should look like right now — and
so belongs beside `language` as a prop, not on the imperative handle
(where it was a request, `setTheme`, because the internal host bridge
speaks the widget API and a widget's host sends theme changes as
requests) and not in the configuration (where `config.theme` only ever
set the starting theme).
The `theme` prop feeds the same channel the rest of Element Call listens
to for a host's theme, replayed so that whatever subscribes after the
host has set it still hears the current one. Changing it re-themes the
container and nothing else; unlike the language, it is per component.
`setTheme` and `config.theme` are gone, and the harness gets a theme
picker in place of its per-pane buttons.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The component bundled English alone: the standalone app fetches its
locale files at runtime from URLs its own build emits, which a host
serving the library from elsewhere could not resolve, so bundling one
language was the self-contained option. Now every locale is a chunk of
its own that the host's bundler loads the first time it is needed, with
English still bundled in so that the fallback never waits.
Element Call starts in the browser's language and follows the host's own
setting through a `language` prop; `supportedLanguages` says what it
accepts. Translations are shared by every Element Call on the page, so
the most recently set language wins for all of them. The harness gets a
language picker, and the app and the component share the parsing of
locale paths.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The call reached for react-router in five places to send the user
"home": on leaving without a lobby, from the lobby's recents link, from
the post-call screen, from the error page's return button and from the
header logo. Home is the standalone app's home page; the call has no
idea where that is, and a component has no such place at all — its host
decides what follows a call. Yet the component had to mount a
MemoryRouter just so those hooks would not throw.
`useLeaveToHome` is the way home as the shell supplies it: the app
provides `navigate("/")` from inside its router, the component provides
nothing, and everything that used to link to "/" now either calls it or,
when there is none, offers no way out. The logo becomes a plain logo,
the recents and "not now" links disappear, the error page's button does
too. `ClientProvider`'s logout goes the same way. The component no
longer renders a router.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`ElementCallConfiguration` was every URL parameter, which put the widget
plumbing (widgetId, parentUrl, isWidget), the user's identity, the
homeserver, the analytics and Sentry settings, and the standalone app's
shared room secret in front of a host that has no business setting any
of them — the component reads none of those. It is now the behavioural
configuration a widget's URL can carry plus the two properties a host
has a say in: the theme and the background. The background is now
actually applied to the component's root, which it was not before.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"Embedded" already means something here: the embedded package is the
widget build. Where a comment meant Element Call running as a React
component inside a host application, it now says so, and the params it
starts from are `componentProperties`. "Host" stays the word for the
application on the other side, whether that is a widget container or an
application rendering the component.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Three layers stood between a shell and the call: `ElementCall` (the
component), `ElementCallView` and `GroupCallView`. The middle one only
held the `joined` flag and the mute state, both of which nothing outside
the call reads any more, so it is folded into the view it wrapped. That
view is now `CallView`, since "group call" is a name from before
Element Call handled anything else.
Its docstring says what it is: the whole lifecycle of a call — lobby,
the call itself, and what comes after — with the lobby and the post-call
screen each present or skipped depending on the parameters and the host.
Both the standalone RoomPage and the component render it directly.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The component exposed the internal HostBridge to hosts as-is, which
carried the host's requests as rxjs observables. That made rxjs part of
the public API of a package that bundles its own copy of it, so a host
would build bridges with a different rxjs than the one Element Call
consumed them with — and asked every host to learn rxjs to change the
theme.
A component host now implements plain async callbacks for what Element
Call tells it (`ElementCallHostBridge`, all optional), and makes its own
requests through an imperative handle on the component's `ref`
(`ElementCallHandle`: setTheme, join, hangUp, setDeviceMute), each
resolving once Element Call has acted and rejecting when nothing in
Element Call can. `component/host.ts` adapts that to the HostBridge the
rest of Element Call still speaks, with a bridge whose identity never
changes, so a host re-creating its callbacks on render restarts nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Only the call body sat inside an error boundary; anything thrown in the
lobby, the mute state or the providers propagated into the host's React
tree, where React unmounts everything up to the nearest boundary — the
host's, or the host's whole application. The standalone app has a
boundary at its top; the component now has the same, showing the error
page inside its container and asking the host to stop keeping it on
screen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The move from media queries to `@container element-call` queries left a
few places still asking the viewport: the room header's compact size
and the lobby's footer placement went through `useMediaQuery`, the
lobby's video preview was `50vh` tall, the reaction picker was capped at
`100vw`, and the content insets and the gradient background were sized
from `100vw`/`100vh`. Embedded in a corner of a host's page, each of
those answered for the page rather than the corner.
`useRootSizeMatches` is `useMediaQuery` for the root element, built on
the same `observeElementSize$` the layout uses; the lengths become
container units. Container units resolve against the nearest query
container, and there are others in the tree (the spotlight layouts, the
media tiles), so base.css says when they may be used.
jsdom gives the body no size at all, which would have every such query
read as a tiny window; the test setup now gives it a desktop's, matching
what the media query mock already answered.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Whether to offer the profile settings was inferred from whether the host
could close Element Call. For a component with no host bridge — the
default — nothing could, so an embedded Element Call let the user edit
the profile of an account that belongs to the host application.
`HostBridge.supportsProfileChanges` states it directly: true standalone,
where Element Call signed the user in itself; false for a widget's host
and for anything embedding the component (which sets it itself, since
the client it hands over is its own). The profile tab and the profile
shortcut follow that. What a host's ability to close us still decides —
what to show after the call ends — is a question about who owns our
lifetime, and stays keyed on `close`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The formatter wants the layered import of the Compound design tokens on
one line; "de-globalise styles" left it wrapped.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"de-globalise styles" gave the tab's <pre> elements a class instead of
styling the bare element, but did not update the snapshot.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The call and the lobby each set `document.title`, so a component
embedded in a host renamed the host's tab to "Element Call | <room>".
The title belongs to whoever owns the page: the standalone app's
RoomPage now sets it, for whichever room it has got as far as knowing
about, and the call itself no longer touches it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The call's shortcuts were listened for on the window and allowed
whenever focus was inside the standalone app's `#root` — which, for a
component embedded in a host, is the host's own root, or nothing. So m,
v and space fired while the user typed in the host's composer, and two
Element Calls on one page both answered every key.
Listen on the element Element Call treats as its root instead (the body
standalone, so nothing changes there), and judge whether a key press is
spoken for by what has focus — a dialog or a text field — rather than by
where it sits in the DOM, since the modals are now portalled to that
same root.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The component built its `MediaDevices` once, in a scope nothing ever
ended, so every mount left device observers running for the rest of the
page's life. Building it in an effect ties the scope to the component's
lifetime — and to the options it was built with, which were previously
frozen at first render.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Everything downstream of the component's params — the mute state, the
call view model and with it the media connection — is keyed on the
identity of the params object, which was memoised on the identity of
the `config` prop. A host writing `config={{ ... }}` inline, which is
the natural way to write it, therefore tore the whole call down on
every render. The harness happened to pass a constant, so nothing
noticed.
`useStableValue` hands out the same object for as long as a deep
comparison says nothing changed, so an inline config costs nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Splits the config options for the timings of a delayed leave event into two sets: one for when delegation is available (as you can relax the timings and get more stable calls this way), and another for when it's unavailable (as we must continue to gracefully downgrade even after Matrix 2.0 is fully rolled out).
This works by bluntly hitting the delegation endpoints without auth before joining to check for a 404.
Extends the [RemoteTracks] logging from #4235 with the per-publication
encryption flag and the ParticipantEncryptionStatusChanged /
EncryptionError room events.
If a publisher encrypts frames while the subscriber believes the
publication is unencrypted, livekit-client bypasses the cryptor and hands
raw ciphertext to the decoder, which is audible as loud noise bursts.
The reverse mismatch (or a missing/invalid key) drops frames instead.
Neither case is visible in a rageshake today.
`component/package.json` describes the built component as a package
(`@element-hq/element-call-component`: entry, stylesheet subpath, types,
peer dependencies) so that a host can depend on
`github:element-hq/element-call#<ref>&path:/component`. Its `prepare`
script builds on install, since nothing is published yet.
For that the component build now lands in `component/dist` instead of the
repository's `dist`, and `pnpm build:component` also emits the type
declarations (`component/tsconfig.build.json`, `build:component:types`),
which previously had to be produced by hand.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Update ghcr.io/element-hq/element-web:develop Docker digest to a9bd290
* Fix Element Web integration tests
---------
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
Warn when ConnectionManagerData merges participants from a second
connection to the same SFU URL, when more than one LiveKit participant
matches a member's backend identity, and when a scope is ended while one
of its behaviors is still delivering a value (which strands the later
bound subscribers on the old value). The per-member match log now also
names the connection the participant came from.
A single re-entrant emission can only strand a contiguous run of a
behavior's subscribers, so a tile that is both 'speaking' and 'waiting
for media' (a middle subscriber stranded) implies a nested re-entry.
Report the nesting depth and log each new depth once instead of only the
first re-entry, and tag splitBehavior-derived behaviors with their field
name so the warning identifies which behavior tore.
rxjs delivers a nested emission to every subscriber and then resumes
delivering the outer, older value to the remaining subscribers, which
leaves them permanently out of sync. Log the first occurrence per
behavior with a stack trace so the re-entrant path can be identified
from a rageshake.
The widget tests cannot reach what makes a component different, because the
iframe used to guarantee it: that Element Call stays inside the space it was
given, and that two of them can exist in one page. So the development harness
gets driven by Playwright.
Three tests. Two components in one page holding a real call between two devices
of one account. The settings dialog and the reaction picker staying inside the
container the host gave, asserted by bounding box. And the host bridge
reporting in both directions, including a host-initiated mute coming back as a
report of the new state.
The containment test is the one worth having: both of the escapes found by
running the harness by hand — the settings dialog centred on the window, and
the reaction picker at 82vh landing below the container — would have failed it,
and neither was visible to typechecking, linting or the unit tests.
Users and rooms are created through the Synapse admin and client-server APIs
rather than by driving an interface, and the harness now takes its credentials
from its own query string so that a test can say which account and room to use.
A host reading its own URL is proper; it was Element Call doing so that was the
mistake.
Playwright gains a second web server for the harness on port 3001, a Vite dev
server whether or not the app itself is served from Docker, since the harness is
a development page with nothing to build.
A tile shows "Waiting for media" for as long as its MatrixRTC member
cannot be matched to a LiveKit participant. Log each transition of that
match and of the tile's waitingForMedia state so rageshakes can tie a
stuck tile to the LiveKit participant and track events.
Rageshakes contained nothing about the state of remote tracks, so a tile
showing the wrong mute or video state for a member could not be
diagnosed. Log connect/disconnect, publish/unpublish, subscribe/
unsubscribe, subscription failures, remote mute/unmute and stream state
changes on each Connection's logger, and remove the listeners when the
connection scope ends.
Opening settings in an embedded call put the dialog in the middle of the host's
window, spilling outside the container, and with two calls on a page the second
drew over the first's dialog.
The container established a stacking context but not a containing block, which
are two different things and only the first had been done. `position: fixed`
resolves against the viewport unless an ancestor makes itself the containing
block, so the modal scrim and dialog in Overlay.module.css — `fixed`, `inset:
0`, centred, because in the standalone app they are meant to cover the page —
were positioned and sized against the window. Layout and paint containment
makes the container the containing block for those descendants and clips what
we paint to our own box. The second-call-on-top symptom goes with it, since the
dialog now stays inside the first call's box and there is nothing to overlap.
Two sibling components still cannot draw over one another by construction,
neither being able to leave its own stacking context, but that only shows if a
host overlaps them.
Containment clips a box measured in viewport units but cannot resize it, so
anything positioned that way is simply put somewhere outside the container and
disappears. That applied to the reactions overlay, a `100vw` by `100vh` box, and
to the reaction picker, which sits at `82vh` so as to appear near the footer it
belongs to. Both are positioned against whatever Element Call treats as its
root — `[data-overlay-container]` in the app, which is the size of the page, and
the host's container when embedded — so percentages mean the same thing there
and the right thing here. The earpiece overlay is `inset: 0` with no viewport
units, so containment is enough for it.
Three viewport-relative sizes remain, all of which need more than a change of
unit: the lobby's video preview is `50vh` on a flex item with an aspect ratio,
`--content-inset-*` ramps up to a desktop inset from the window width, and the
picker's `max-width` cap is the window's.
The clipping cuts both ways: a menu near the edge of a small container is
trimmed rather than overflowing into the host. That is the trade an embedded
component makes.
The component seeded its parameters with `computeUrlParams()`, which reads
`window.location`. Element Call is not the page any more, so what it found
there was the host's URL: no `widgetId`, therefore not a widget, therefore no
intent, therefore the standalone app's preset.
The visible symptom was the Element Call logo in the footer of a call embedded
in someone else's application, since `showLogo` follows `header ===
HeaderStyle.Standard`. The rest of that preset mattered more. A hosted call
defaulted to `perParticipantE2EE: false` and `confineToRoom: false` — so
unencrypted, and willing to take the user out of the room a host had put them
in — and a host could not correct either without naming every parameter
itself, nor even name `header`, since the enums were not exported.
So the intent presets move out of `computeUrlParams` into
`configurationForIntent`, and the component builds its parameters from an
intent plus the properties a host has no URL to supply. `intent` becomes a
prop, defaulting to joining an existing group call: the lobby first, confined
to the room, encrypted per participant, and no Element Call branding in
someone else's interface. A host that knows which button the user pressed
should say which.
One deliberate difference from the widget presets: the background defaults to
solid rather than the gradient, which is drawn by a `position: fixed`
pseudo-element and would escape the container to cover the host.
`UserIntent.Unknown` still means the standalone preset, so the app and widget
are unchanged — including a widget that names no intent, which has always been
given those defaults.
`pnpm dev:component` serves a page that stands in for a host application: it
signs in twice against the development backend and shows two calls side by
side, in resizable boxes, with furniture of its own around them. Two devices of
one account, so a real call happens between the two components and anything
Element Call keeps once per process rather than once per call shows itself.
The host bridge is driven by hand and reports both directions in a log along
the bottom, which is the first exercise the theme, hang-up and device-mute
requests have had outside widget mode. Each pane can be unmounted and remounted
to see what Element Call leaves behind, and there is a `position: fixed` dialog
belonging to the host to see whether it covers the calls. The page uses none of
Element Call's design tokens, so anything that looks styled outside a pane came
from Element Call reaching out of its container. It reaches Element Call only
through the component's public interface, which is how the exports missing from
that interface came to light.
Three things about the component build the harness turned up on the way, all
too small to be worth their own commits:
- It copied `public/` into `dist/`, including the developer's own gitignored
config.json, into output we would publish. `publicDir: false`, as the
embedded build already does. The sdk build has the same leak; untouched.
- `pnpm lint:externals` now exists, which the build config already claimed it
did. It reads the external list out of that config and fails if the source
imports React, the Matrix SDK or LiveKit by a path the list does not name.
Since the bundler silently ignores the pattern form of that option, an
unnamed subpath is bundled with no warning at all — which is how a host would
end up with a second React.
- `lint:oxlint` ran over `src playwright`, so nothing in `component/` had ever
been linted.
Serving a page also meant the shared plugin list could no longer inject the
app's HTML entry point unconditionally, so that is now optional — and off for
the library build too, which never had an HTML page to inject it into.
The component built and typechecked in the previous commit, but only because
nothing had rendered it. Everything Element Call needs that `src/main.tsx`
side-loads was missing from it.
Its stylesheet: only main.tsx imported index.css, so the library build emitted
CSS-module styles with every `--cpd-*` and `--font-size-*` unresolved. Split
into base.css, which both the app and the component import, and the rules that
are about owning a page, which only the app does. The split is a straight move
— comment-stripped and sorted, the old file and the two new ones differ by
exactly one line — and that line is the deliberate part: `.no-scroll-body`
becomes `body.no-scroll-body`. Element Call adds that class to whatever it
treats as its root, and since the root can now be a container, `position:
fixed` would have taken that container out of the host's layout. Pinning the
page is what it always meant.
Its translations: `initializeElementCall` called `i18n.init` with neither
resources nor a backend, so every key would have rendered as itself. English is
bundled in. The app fetches locale files from URLs its own build emits, which a
host serving the library from somewhere else could not resolve, so how a host
picks a language is left open.
And the types a host needs: `HostBridge` alone is not enough to implement
`HostBridge` — `HostRequest`, `DeviceMuteState`, `DeviceMuteRequest` and
`JoinCallData` all appear in its signatures, and `ConfigOptions` in
`initializeElementCall`'s.
toggleScreenSharing only had `.catch(logger.error)`, so a getDisplayMedia
request that hangs (element-call-rageshakes#17152: Element Desktop on
Windows, the user pressed the screen share button 14 times in 25 seconds
and the log shows nothing but the toggle lines and livekit-client's
"waiting for pending publication promise timed out") left the user with
a button that does nothing and us with no evidence of why.
Log when a toggle is requested and when it completes or fails, with the
elapsed time, so a hang is visible in the logs. Explicit failures other
than the user cancelling the picker show as a non-modal "Could not start
screen sharing" toast. Nothing is inferred from a toggle taking a long
time: the user may simply be choosing what to share.
If the camera track has already ended by the time the blur processor is
applied to it, MediaStreamTrackProcessor cannot be constructed and
setProcessor rejects. Both call sites used `void`, so this surfaced as
Unhandled promise rejection: TypeError: Failed to construct
'MediaStreamTrackProcessor': Input track cannot be ended
(element-call-rageshakes#17225). Skip ended tracks, and catch and log
processor attach/detach failures instead of leaking them.
When the ConnectionManager stops a Connection while livekitRoom.connect()
is still pending (which happens on every join, because the local
membership emits twice in quick succession and the connection set is
recomputed), livekit-client rejects the pending connect with "Client
initiated disconnect". Connection.start() then treated that as a failure:
it pushed an UnknownCallError into the state of a connection that was
already stopped and rethrew, and since start() is fire-and-forget the
throw surfaced as
Unhandled promise rejection: Error: Failed to connect to Livekit server
in every rageshake, right after a "livekitRoom.connect FAILED ... Client
initiated disconnect" line. Nothing was actually wrong: the replacement
connection connects fine a moment later.
Mark the connection as stopped before disconnecting, and have start()
return quietly when the rejection is the abort we asked for.
Adds component/index.tsx as a fourth build target: <ElementCall client
roomId /> and an initializeElementCall to await once beforehand. It gives
Element Call everything it would otherwise take from the page it is on —
the parameters, the host bridge, media devices, translations, a container
to confine itself to — and hands it the host's client rather than finding
one of its own.
React, the Matrix SDK and LiveKit stay external, since the host has them
and a second copy of any would not merely be wasteful: React would hold
two sets of hooks and the client would run two sync loops. Every subpath
has to be listed by name, because the pattern and callback forms of
rollupOptions.external are silently ignored here — a lesson worth the
comment that records it.
Element Call's own navigation runs in a MemoryRouter, so being embedded
cannot disturb the host's URL. ClientContext and GroupCallView both
navigate, so some router has to be present.
The bundle is not yet a reasonable size: library mode base64-inlines
assets referenced through import.meta.url, so MediaPipe's vision runtime
lands in it whole. Left for its own change, since the fix — loading the
background blur transformer lazily — is worth doing for the standalone app
too.
ElementCallView took muteStates as a prop, which would have meant an
embedder building one before it could show a call. It could not simply make
its own: RoomPage created one on mount whichever branch it went on to
render, and two would both report the user's mute state to the host,
talking over each other.
Move the construction into a useMuteStates hook, and give the lobby shown
while waiting to be let into a room its own component. Each lobby now holds
mute state only while it is on screen, so there is never a second one, and
the component can own the call's.
KnockLobbyView also takes the room summary and label handling that RoomPage
was assembling on its behalf, leaving the page with arriving at a call
rather than being in one.
Six selectors named `body` directly — the gradient backdrop and the
platform font overrides in index.css, and the iOS adjustments in
AppBar.module.css and Modal.module.css — so they only applied when Element
Call owned the page. A host mounting it into a container would have got an
interface decorated correctly and styled incorrectly, with nothing to show
that anything was wrong.
Mark the root element with data-element-call-root and match on that
instead. Scoping this way keeps the selectors more specific than they were,
rather than less: widening them to a bare [data-platform=…] would have
dropped specificity from (0,1,1) to (0,1,0) and changed which rules win.
The platform attribute moves with them, from the initializer's write onto
document.body to a layout effect on the root, alongside the theme — so it
still lands before anything is painted.
No visual change while Element Call owns the page: the root is the body,
which now carries the attribute, so every rewritten selector matches the
element it always did.
Being in a call and deciding to show one are different jobs, but RoomPage
did both: routing, authentication, resolving room aliases and knocking, and
then the call itself. Only the first set belongs to whatever is hosting
Element Call.
Add ElementCallView as the seam between them. It takes a client and a call
to join, and owns whether the user has joined — which is the call's own
business rather than its host's. RoomPage keeps everything about arriving
at a call and renders this for the call itself.
Nothing else moves yet. muteStates is still passed in, because the
standalone shell shares one with the lobby it shows while waiting to be let
into a room, and two instances would both report the user's mute state to
the host.
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.
The throttled flush callback returned this.flush instead of calling it
(regressed in #2607), so logs were only persisted to IndexedDB on
rageshake submission or beforeunload. When the host removes the widget
iframe at hangup, the whole call's logs were lost, so a rageshake filed
from a later call carries nothing from the affected one.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
This is the mode in which we sent membership events with the 'oldest membership' transport selection algorithm, which stopped being the default back in version 0.21.0. Users will no longer be able to select this mode in developer settings, and admins will no longer be able to select legacy mode through the config either. The app will still continue to support *receiving* membership events with the 'oldest membership' transport selection algorithm from others, however.
Support for events with an array of memberships from different devices was removed over a year ago in matrix-js-sdk ffd3c9575e9def576739baf6b1dc329b0db55c0c.
It turns out that for a blurry gradient background, low-res raster images look very convincing when scaled up and interpolated. That means that we can rasterize our gradient SVGs for improved paint performance when resizing the window, while still keeping them quite lightweight (just a couple of kilobytes each).
When someone starts or stops speaking, the layout will often be recomputed only to find out that there is ultimately no layout change. We can ignore these redundant updates to avoid re-rendering the InCallView and Grid components, which are relatively slow.
For that specific, common case, this reduces JS CPU usage by as much as 70% in my testing.
It's very uncommon to have this debug option enabled, and yet it currently causes the footer to re-render on every layout update, which is a small but avoidable cost.
I don't believe this affects the cost of running the useSprings hook, but it at least skips the animation frames that would otherwise follow, so it's worth a try.
It turns out that the timers which repeatedly poll the RTP stats of each video track all run independently of each other and can add up to a small but constant sink of CPU. Meanwhile we can replace these timers with HTMLVideoElement 'resize' event listeners, which is way more efficient and reacts instantly to orientation changes.
A change in a tile's speaking indicator could cause its entire tree of context menu components to re-render, which is expensive. Isolating the behavior subscriptions in their own component avoids this.
With the React Compiler, our component code is transformed at build time to automatically apply various forms of memoization. This changes the runtime semantics of our code a little bit and therefore could surface new bugs in the next release cycle in case any components fail to follow the rules of React. See https://react.dev/learn/react-compiler for more information.
This results in a small bundle size increase and modest performance gains, but I think it's worth it given the potential for larger performance gains once we tune the component structure a bit more.
* Update ghcr.io/element-hq/element-web:develop Docker digest to 0183c0a
* Update end-to-end tests to work with auto-collapsing room list
---------
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
On the combined Storybook docs page for the call footer, the layout switches would not show the correct state until you hovered over them because they all shared the same input name and thus were interpreted as belonging to the same radio group.
It reduces confusion when reading snapshots if class names are still scoped to their respective CSS modules. It also discourages the use of class names in tests, which is a good thing. (https://testing-library.com/docs/guiding-principles)
Thanks to Johannes for the suggestion.
The rule should only care about enclosing function/class scopes. For example if an ObservableScope is received as a parameter to a function and then simply used inside an 'if' block (technically a different scope), that's not a problem.
Previously we were hiding the entire app bar on mobile phones in landscape orientation. However now that the app bar supports a small 'subtitle' element, we should show only the subtitle in this case to match the designs.
The subtitle still hides on tap, just like the footer.
On mobile, the ringing status indicator is supposed to display in the header rather than on a tile. The exact layout differs between Android and iOS. To get it right I had to refactor AppBar to use CSS grid templates.
(Also, I changed my mind about the exact ringing data I needed out of CallViewModel - sorry. A little move of the ringtone audio renderer into its own component was necessary to accommodate that.)
It was rather confusing that matrixLivekitMembers$ gives you objects of type RemoteMatrixLivekitMembers and yet the *local* member would often be among these. I've attempted to clear this up. To my knowledge this wasn't creating any bugs.
The rule of thumb to avoid resource leaks is that you should never call ObservableScope methods in a callback unless the ObservableScope is directly passed to or created inside that callback. I had a go at codifying this as a lint rule.
I noticed that calls to createDisplayNameBehavior$ and createAvatarUrlBehavior$ were technically leaking resources since they reused the ObservableScope from their outer scope, which in practice lasts for the entire lifetime of the CallViewModel. This would not have had any noticeable effect unless you had other participants leave and rejoin the same call many thousands of times.
I found our code's internal model of ringing a little overgrown (it had superfluous states like 'unknown') and difficult to extend with metadata or callbacks relating to ring attempts. By modeling ringing instead as a stream of ring attempts, where each attempt has an intent, a recipient, and an eventual outcome (accept/decline/timeout), I find it more natural to work with.
This makes room for a future 'try again' callback to allow ringing someone again after a timeout, and also forced me to look for a simpler solution to the duplicate leave sound effects. I exposed the intent of the ringing attempt to the call UI so I can later use it in the header.
The designs actually never want us to show the settings button in the footer if an app bar is in use (as in Element X mobile apps), so just avoid showing it at all in that case.
Because the type of header that we use in Element X (an 'app bar') lives in a different place in the document than the other headers, it needs a special branch to propagate the right insets.
* Posthog: drop $initial_person_info from outgoing events
* Posthog: migrate from sanitize_properties to before_send
* strip URL fields from $set / $set_once
* enable mask_personal_data_properties
* review
* update tests to check for `delete` (not anymore `=null`)
rename: `applyPrivacyFilters`->`santizeSensitiveData`
---------
Co-authored-by: Timo K <toger5@hotmail.de>
* Add Posthog events for Call reconnect including the reason
* Expose single trackCallReconnecting() entry point on PosthogAnalytics
* Track reconnect duration and align with existing analytics pattern
* Refactor combined$ to return [connected, reason] tuple
* Update firefoxUserPrefs to allow getUserMedia and enumerateDevices on CI
---------
Co-authored-by: Valere <bill.carson@valrsoft.com>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Timo K <toger5@hotmail.de>
* add MeidaMuteAndSwitchButton
* User button in footer
* Add tests
* update styling (dark bg on menu open + chevron white + chevron up)
* fix tests
* add storybook to CI
only add storybook with storybook label
test names
another env name test
TestName
new default name
remove label condition
Update pr-deploy.yaml
* Update pr-deploy.yaml
* add toggle example to default component
* hook up footer select actions
* fix video audio button (swapped) and lable in story
* make `delayed_leave_event_delay_ms` and `network_error_retry_ms` mandatory
* Support delegation for legacy jwt request
* Calculate `maximumNetworkErrorRetryCount` based on timeouts
* add storybook to CI
only add storybook with storybook label
test names
another env name test
TestName
new default name
remove label condition
Update pr-deploy.yaml
* rename things to check if we actually run the latests ci
* cleanup things used for testing
* Update deploy-to-netlify.yaml
* use package instead of custom environment_name
* final cleanup
By adding viewport-fit=cover to the <meta name="viewport"> header, the page now requests to be displayed edge-to-edge across the entire screen. This gives us control over what we display around camera cut-outs and system navigation UI, if the user agent supports it. I then adjusted the styles of various UI elements to ensure that they still lie within the screen's safe area.
* Update compound-web to 9.3.0 and update Buttons from "sm" to "md".
* Clean up the liast bits
* Update snaps too
* fix test, need to dismiss a new dialog
---------
Co-authored-by: Valere <bill.carson@valrsoft.com>
Run `yarn i18n` to extract new translation keys for the media quality
settings, and update the DeveloperSettingsTab snapshot to include the
Camera quality, Screen sharing, and Audio processing sections.
Allows Element Call to be served from a subdirectory (e.g. /widgets/element-call/
in Element Web) without breaking dynamic imports for locales, workers, and other
assets that were previously using absolute paths.
Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
- Add camera video quality controls (resolution/framerate/bitrate/codec)
to Settings > Video, mirroring the screen share settings UI
- Add audio processing toggles (echo cancellation, noise suppression,
auto gain control) to Settings > Audio, replacing URL-param-only controls
- Display raw values inline on all sliders (framerate, bitrate, volume)
- Add config-seeded defaults: config.json media_quality values now seed
Setting defaults for users who haven't explicitly set preferences
- Camera settings are applied when joining a call via ConnectionFactory
Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
InputField only supports input/textarea, not select. Passing option
children caused React to crash rendering children inside a void input.
Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
Adds a "Screen sharing" section to Settings > Video with controls for:
- Resolution (576p to 4K)
- Framerate (5-60 fps slider)
- Bitrate (0.5-15 Mbps slider)
- Codec (VP8/VP9/H.264/AV1)
Gated behind an "Advanced screen share settings" toggle. When enabled,
settings are passed to LiveKit's setScreenShareEnabled as both capture
constraints and publish options. When disabled, falls back to
config.json media_quality defaults.
Settings are persisted in localStorage via the existing Setting<T>
system. The Slider component is extended with a tooltipFormatter prop
for custom tooltip display.
Inspired by pirosuki's advanced-screen-share-settings branch, but
reimplemented cleanly: settings are read directly in LocalMember.ts
(no signature changes), the existing Slider is extended (no component
duplication), and proper form components are used throughout.
Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
Add a `media_quality` section to config.json that allows self-hosters
to configure video codec, resolution, bitrate, framerate, and simulcast
layers for both camera and screen sharing.
This addresses the long-standing request in #249 for configurable media
quality settings. The LiveKit SDK already supports all of these options;
this change exposes them through the existing config system.
New config.json fields:
- media_quality.video_codec: preferred codec (vp8/vp9/h264/av1)
- media_quality.video: camera resolution, bitrate, framerate, simulcast layers
- media_quality.screen_share: screen share resolution, bitrate, framerate,
simulcast layers (enables 3+ layer simulcast for screen sharing)
All fields are optional and fall back to the existing defaults (VP8,
720p camera, 1080p screen share) when not specified.
Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
They were not properly being contained to where the MediaView is supposed to appear, causing them to all stack up on the first screen share in the spotlight tile.
6667fc54c0 changed this CSS selector that shows buttons on hover to only target the buttons in the bottom right corner of the spotlight tile, causing the forward/back buttons to stay invisible.
This approach is more flexible in that it allows even the local participant to share their screen in CallViewModel tests, and more rigorous in that it ensures that application code is reacting specifically to track publications.
If you manage to move your floating video tile to the bottom of the screen in a small landscape window, the footer obscures the tile when shown. The designs want us to smoothly move the floating tile out of the way in this case.
This adds two new intents: start_call_voice and join_existing_voice. I need the latter in order to implement Element Web's new incoming call toasts, in which you can turn off your video before joining a group call. The other one, start_call_voice, exists more for completeness than anything; we don't currently want to allow users to start voice calls in group chats in our messenger clients, but maybe Cinny would, for instance.
- use the new domain logic to discover the transport
- then try to authenticate
- Also fix the bug in multi sfu where active$ not updated on delayId change
If you are the only participant in the call, the expanded spotlight layout would redundantly show your media in both the spotlight and PiP tiles. This is a regression; in versions 0.16.1 and earlier we would avoid showing the same user twice.
Taking Valere's suggestion of giving them the 'switch' role. Also, the aria-label attributes were redundant (having tooltips already gives the buttons aria-labelledby).
Add comments on existing code
Extracted a specific android controller for isolation and better testing
lint fixes
Fix device update logic and more tests
better typescript
In landscape orientation the button would be buried underneath the footer, which would block interaction with it. This commit changes the footer to not show in cases where a button has been pressed.
Timo and I agreed previously that we should ditch the class pattern for view models and instead have them be interfaces which are simply created by functions. They're more straightforward to write, mock, and instantiate this way.
The code for media view models and media items is pretty much the last remaining instance of the class pattern. Since I was about to introduce a new media view model for ringing, I wanted to get this refactor out of the way first rather than add to the technical debt.
This refactor also makes things a little easier for https://github.com/element-hq/element-call/pull/3747 by extracting volume controls into their own module.
To correctly implement the legacy "oldest membership" mode, we need the code to be more nuanced about the local transport. Specifically, it needs to allow for the transport we advertise in our membership to be different from the transport that we connect to and publish media on. Otherwise, if these two are yoked together, members will resend their memberships whenever an SFU hop occurs, which an attacker could use to cause an amplified wave of state changes.
It's always worth having logs for when state holders are created or destroyed (these are often the most interesting things happening in the application), so I thought it would be nice to have generateItems always log for you when it's doing that.
once)
The local jwt token needs to be aquired via the right endpoint. The
endpoint defines how our rtcBackendIdentity is computed. Based on us
using sticky events or state events we also need to use the right
endpoint. This cannot be done generically in the connection manager. The
jwt token now is computed in the localTransport and the resolved sfu
config is passed to the connection manager.
Add JWT endpoint version and SFU config support Pin matrix-js-sdk to a
specific commit and update dev auth image tag. Propagate SFU config and
JWT endpoint choice through local transport, ConnectionManager and
Connection; add JwtEndpointVersion enum and LocalTransportWithSFUConfig
type. Add NO_MATRIX_2 auth error and locale string, thread
rtcBackendIdentity through UI props, and include related test, CSS and
minor imports updates
On second glance, the way that we determined a media tile to be 'waiting for media' was too implicit for my taste. It would appear on a surface reading to depend on whether a participant was currently publishing any video. But in reality, the 'video' object was always defined as long as a LiveKit participant existed, so in reality it depended on just the participant. We should show this relationship more explicitly by moving the computation into the view model, where it can depend on the participant directly.
We would show 'waiting for media' on participants that were connected but had no published tracks, because we were filtering them out of the remote participants list on connections. I believe this was done in an attempt to limit our view to only the participants that have a matching MatrixRTC membership. But that's fully redundant to the "Matrix-LiveKit members" module, which actually has the right information to do this (the MatrixRTC memberships).
This fixes a regression on the development branch: the layout switcher would not respond to input while the window mode is 'flat' (i.e. while a mobile phone is in landscape orientation). See https://github.com/element-hq/element-call/pull/3605#discussion_r2586226422 for more context.
I was having a little trouble interpreting the emergent behavior of the layout switching code, so I refactored it in the process into a form that I think is a more direct description of the behavior we want (while not making it as terse as my original implementation).
Semantically, behaviors are only meaningful for as long as their scope is running. Setting a behavior's value to an empty array once its scope ends is not guaranteed to work (as it depends on execution order of how the scope is ended), and subscribers should be robust enough to handle clean-up of all connections at the end of the scope either way.
Since we now bundle a trusted Element Call widget with our messenger applications and this widget reports analytics to an endpoint determined by the messenger app, there is no longer any reason to compute a different analytics ID from the one used by the messenger app.
While looking into what had regressed https://github.com/element-hq/element-call/issues/3588, I found that 28047217b8 had filled in a couple of behaviors with non-reactive default values, the "natural window mode" behavior being among them. This meant that the app would no longer determine the correct window mode upon joining a call, instead always guessing "normal" as the value. This change restores its reactivity.
echo "[yarn-linker] The pre-commit hook has disabled .links.yaml and MODIFIED the yarn.lock file. Review the staged changes (the hook added yarn.lock, was this desired?) and run \`git commit \` again if they look okay. The post-commit hook will re-enable your links."
# Checks if there currently is linking configured. Informs the user to disable linking before committing.
PNPMFILE=.pnpmfile.cjs
if test -f "$PNPMFILE"; then
echo "[pnpm-linker] The pre-commit hook detected $PNPMFILE which implies you have linked packages in your pnpm-lock.yaml. Run pnpm links:off and commit again. See also linking.md."
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "eslint-plugin-rxjs"
],
"categories":{
"correctness":"error",
"perf":"error"
},
"options":{
"denyWarnings":true,
"typeAware":true
},
"env":{
"builtin":true
},
"rules":{
"element-call/copyright-header":[
"error",
"/*\nCopyright %%CURRENT_YEAR%% Element Creations Ltd.\n\nSPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\nPlease see LICENSE in the repository root for full details.\n*/\n\n"
and [LiveKit](https://livekit.io/). It runs in multiple deployment contexts — as a
standalone web app and as a widget embedded in Element Web, Element X iOS, and
Element X Android. It is also the primary R&D foundation for MatrixRTC, which means
its architecture, maintainability, and flexibility are held to a high standard.
We welcome contributions from the community. This document explains how to
contribute effectively so that both you and the maintainers get the best outcome.
## Issue First Policy
> [!IMPORTANT]
> Before writing a single line of code for a new feature or UI change, you **must**
> open an issue and have the approach agreed with the maintainers.
>
> **We will not review or merge feature or UI pull requests that arrive without a
> corresponding, pre-approved issue.**
This is not gatekeeping — it's how we prevent wasted effort on both sides. Element
Call must work correctly across multiple deployment contexts and meet specific product
and design requirements. It is also a fast-moving codebase that underpins ongoing
MatrixRTC development. A PR that looks reasonable in isolation can easily conflict
with in-progress work, planned architecture changes, or design decisions that haven't
been publicly documented yet.
The issue is where we resolve all of that **before** anyone writes code.
**Bug fixes** are no exception — most confirmed bugs should already have an issue anyways, existing issues that are marked as bugs have an implicit maintainer approval. If the solution for the bug is controversial it is highly recommended to discuss the approach in the issue before opening a PR.
## Contribution Workflow
1.**Open an issue** using the [Enhancement request](https://github.com/element-hq/element-call/issues/new?template=enhancement.yml) template.
2.**Wait for feedback.** A maintainer will comment on the issue **within two weeks**. The use case and approach will get dicussed.
This may involve questions, suggestions, or a request to adjust scope.
This also allows to bring design and product into the loop before code gets created.
3.**Get a green light.** Wait for explicit approval from a maintainer before starting
implementation.
4.**Implement.** Write the code against the agreed approach.
5.**Open a PR.** Link to the issue in your PR description and satisfy the checklist
in the PR template.
## Code Quality
Element Call moves fast and the codebase must stay clean and maintainable.
- **Take responsibility for AI-generated code.** AI tools can be a useful aid, but we expect all the generated code to be understood and reasoned about by the contributor. Questions by the maintainers should be answered without just forwarding them to AI. The maintainers also have access to AI tools. If your contribution is just transporting messages between LLM <-> maintaines all our time is better used if the maintainers decide to interact with AI for this specific problem by themselves.
- **Think across deployment contexts.** Changes must work correctly in both standalone
and widget modes. Consider how your change interacts with Element Web, Element X
iOS, and Element X Android.
- **Write tests.** New functionality should be covered by tests. Where it is feasible,
existing uncovered code touched by your PR should also gain tests.
@@ -35,8 +36,9 @@ You can find the latest development version continuously deployed to
✅ **Decentralized & Federated**– No central authority; works across Matrix
homeservers.
✅ **End-to-End Encrypted**– Secure and private calls.
✅ **Standalone & Widget Mode**– Use as an independent app or embed in Matrix
clients.
✅ **Standalone, Widget & Component Mode**– Use as an independent app, embed
in Matrix clients as a widget, or (experimentally) mount it as a React component
inside your own application.
✅ **WebRTC-based**– No additional software required.
✅ **Scalable with LiveKit**– Supports large meetings via SFU
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
@@ -65,7 +67,7 @@ requiring a separate Matrix client.
### 📲 In-App Calling (Widget Mode in Messenger Apps)
When used as a widget 🧩, Element Call is solely responsible on the core calling
When used as a widget 🧩, Element Call is solely responsible for the core calling
functionality (MatrixRTC). Authentication, event handling, and room state
updates (via the Client-Server API) are handled by the hosting client.
Communication between Element Call and the client is managed through the widget
@@ -89,7 +91,9 @@ and voice calls within Matrix rooms.
Element Call offers two packaging options: one for standalone or widget
deployment, and another for seamless widget-based integration into messenger
apps. Below is an overview of each option.
apps. A third, experimental option builds it as a React component library for
applications that want to render a call inside their own page rather than in an
iframe. Below is an overview of each option.
**Full Package**– Supports both **Standalone** and **Widget** mode. It is
hosted as a static web page and can be accessed via a URL when used as a widget.
@@ -106,38 +110,46 @@ recommended method for embedding Element Call.
"description":"Element Call as a React component. Consumed straight from the repository as a git dependency (github:element-hq/element-call#<ref>&path:/component): the host's package manager runs `prepare`, which builds `dist/`.",
The file is located at `backend/dev_tls_local-ca.crt`. Transfer it via:
- Matrix room
- AirDrop for iphone
### IOS Setup
**Install the certificate profile on iPhone**
- Open the `dev_tls_local-ca.crt` file on your iPhone
- You'll see "Profile Downloaded"
- Go to **Settings → General → VPN & Device Management** (or **Settings → General → Profiles**)
- Tap the "Element Call Dev CA" profile
- Tap **Install** and enter your passcode
- Confirm by tapping **Install** again
**Enable full trust (Critical!)**
- Go to **Settings → General → About → Certificate Trust Settings**
- Under "Enable Full Trust for Root Certificates"
- Toggle **ON** for "Element Call Dev CA"
- Confirm the security warning
**Access Element Call**
Find your laptop's IP address (e.g., `192.168.0.122`) and use one of these URLs in Safari to validate:
```
https://192-168-0-122.nip.io:3000/
```
**For Element X iOS Developer Tools**
In Element X's developer settings, set the Element Call URL to the nip.io url (replace . with - in the IP address):
```
https://192-168-0-122.nip.io:3000/room
```
### Android Setup
**Transfer the CA certificate to your Android device**
The file is located at `backend/dev_tls_local-ca.crt`.
**Install the certificate**
This might vary by Android version and manufacturer, but generally:
- Open **Settings** search for "CA Certificate"/"Certificate"
- Tap **Install a certificate** or **Install from storage**
- Select **CA certificate**
- Confirm the security warning
- Navigate to and select the `dev_tls_local-ca.crt` file
- Give it a name like "Element Call Dev CA"
**Access Element Call**
Find your laptop's IP address (e.g., `192.168.0.122`) and use one of these URLs in Chrome to validate:
```
https://192-168-0-122.nip.io:3000/
```
**For Element X Android Developer Tools**
In Element X's developer settings, set the Element Call URL to the nip.io url (replace . with - in the IP address):
```
https://192-168-0-122.nip.io:3000/room
```
### Why nip.io?
[nip.io](https://nip.io) is a free wildcard DNS service that automatically resolves domain names containing IP addresses. For example, `192-168-0-122.nip.io` automatically resolves to `192.168.0.122`. This means:
- No need to regenerate certificates when your laptop's IP changes
- Works from any device without DNS configuration
- iOS/Android treat it as a proper domain name, not an IP address
- One-time certificate setup works for all future IP addresses
> [!IMPORTANT]
> Make sure your network router doesn't enforce DNS rebinding protection (which will
> break nip.io). If it does, try allow-listing nip.io in your router's administration interface.
@@ -7,12 +7,15 @@ A few aspects of Element Call's interface can be controlled through a global API
-`controls.canEnterPip(): boolean` Determines whether it's possible to enter picture-in-picture mode.
-`controls.enablePip(): void` Puts the call interface into picture-in-picture mode. Throws if not in a call.
-`controls.disablePip(): void` Takes the call interface out of picture-in-picture mode, restoring it to its natural display mode. Throws if not in a call.
-`controls.onPipMediaOrientationUpdate: ((orientation: "landscape"|"portrait") => void) | undefined` Callback called whenever the PiP media orientation changes.
The client should track this value to already initiate the pip in the right orientation.
It should update the orientation of the current Pip window when called.
## Audio devices
On mobile platforms (iOS, Android), web views do not reliably support selecting audio output devices such as the main speaker, earpiece, or headset. To address this limitation, the following functions allow the hosting application (e.g., Element Web, Element X) to manage audio devices via exposed JavaScript interfaces. These functions must be enabled using the URL parameter `controlledAudioDevices` to take effect.
-`controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?, boolean; }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
-`controls.setAvailableAudioDevices(devices: { id: string, name: string, forEarpiece?: boolean, isEarpiece?: boolean isSpeaker?: boolean, isExternalHeadset?: boolean }[]): void` Sets the list of available audio outputs. `forEarpiece` is used on iOS only.
It flags the device that should be used if the user selects earpiece mode. This should be the main stereo loudspeaker of the device.
-`controls.onAudioDeviceSelect: ((id: string) => void) | undefined` Callback called whenever the user or application selects a new audio output.
-`controls.setAudioDevice(id: string): void` Sets the selected audio device in Element Call's menu. This should be used if the OS decides to automatically switch to Bluetooth, for example.
| **Responsibility for regulatory compliance** | The administrator that is deploying the app is responsible for compliance with any applicable regulations (e.g. privacy) | The developer of the messenger app is responsible for compliance |
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url-params.md#embedded-only-parameters) if consent has been granted. |
| **Analytics consent** | Element Call will show a consent UI. | Element Call will not show a consent UI. The messenger app should only provide the embedded Element Call with the [analytics URL parameters](./url_params.md#embedded-only-parameters) if consent has been granted. |
| **Analytics data** | Element Call will send data to the Posthog, Sentry and Open Telemetry targets specified by the administrator in the `config.json` | Element Call will send data to the Posthog and Sentry targets specified in the URL parameters by the messenger app |
### Using the embedded package within a messenger app
@@ -25,8 +28,8 @@ The basics are:
1. Add the appropriate platform dependency as given for a [release](https://github.com/element-hq/element-call/releases), or use the embedded tarball. e.g. `npm install @element-hq/element-call-embedded@0.9.0`
2. Include the assets from the platform dependency in the build process. e.g. copy the assets during a [Webpack](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/webpack.config.js#L677-L682) build.
3. Use the `index.html` entrypointof the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20)
4. Set any of the [embedded-only URL parameters](./url-params.md#embedded-only-parameters) that you need.
3. Use the `index.html` entrypointof the imported assets when you are constructing the WebView or iframe. e.g. using a [relative path in a webapp](https://github.com/element-hq/element-web/blob/247cd8d56d832d006d7dfb919d1042529d712b59/src/models/Call.ts#L680), or on the the Android [WebViewAssetLoader](https://github.com/element-hq/element-x-android/blob/fe5aab6588ecdcf9354a3bfbd9e97c1b31175a8f/features/call/impl/src/main/kotlin/io/element/android/features/call/impl/utils/DefaultCallWidgetProvider.kt#L20)
4. Set any of the [embedded-only URL parameters](./url_params.md#embedded-only-parameters) that you need.
## Widget vs standalone mode
@@ -35,5 +38,5 @@ Element Call is developed using the [js-sdk](https://github.com/matrix-org/matri
As a widget, the app only uses the core calling (MatrixRTC) parts. The rest (authentication, sending events, getting room state updates about calls) is done by the hosting client.
Element Call and the hosting client are connected via the widget API.
Element Call detects that it is run as a widget if a widgetId is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters
](./url-params.md).
Element Call detects that it is run as a widget if `widgetId` is defined in the url parameters. If `widgetId` is present then Element Call will try to connect to the client via the widget postMessage API using the parameters provided in [Url Format and parameters
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. Yarn has a command for this (`yarn link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
Run:
Instead, you can use our little 'linker' plugin. Create a file named `.links.yaml` in the Element Call project directory, listing the names and paths of any dependencies you want to link. For example:
Run: 'git commit' with links enabled to test the git pre-commit hook.
Run: 'pnpm links:off' to be able to commit again
Run: 'git config --local core.hooksPath ""' to allow committing with linking (not recommended)
Run: 'rm links.cjs' & 'git config --local core.hooksPath ""' to fully revert what this script did
```
# Developing with linked packages
If you want to make changes to a package that Element Call depends on and see those changes applied in real time, you can create a link to a local copy of the package. `pnpm` has a command for this (`pnpm link`), but it's not recommended to use it as it ends up modifying package.json with details specific to your development environment.
Instead, create a file named `.links.cjs` in the Element Call project directory (or run `./scripts/setup-linking.sh` to create a template), listing the names and paths of any dependencies you want to link. For example:
Background: The presence of the `.pnpmfile.cjs` adds a field to the `pnpm-lock.yaml` called: `pnpmfileChecksum`. This field is a checksum of the content of the `.pnpmfile.cjs` file.
`pnpm install --frozen-lockfile`**fails** if there is a `.pnpmfile.cjs` but no `pnpmfileChecksum` or vice versa (or on mismatch).
_TLDR: running with `--ignore-pnpmfile` will fail if `pnpmfileChecksum` is present._
#### `pnpmfileChecksum` + renovate bot
When the renovate bot creates a PR it runs `pnpm install --ignore-pnpmfile`. This means that the `pnpmfileChecksum` in the lockfile will be **empty**.
This breaks builds that **don't** ignore the `.pnpmfile.cjs`-file. (CI that runs on the renovate PR)
From here we have two possible paths:
- ignore `.pnpmfile.cjs` in all CI builds (CI will also fail if we accidently add it locally).
- fixup the `pnpm-lock.yaml` in the renovate PR to contain the correct `pnpmfileChecksum`.
Ignoring in all CI builds means that CI will always fail if we enable the linking system.
This is annoying but can be worked around with the git hook we provide that at least lets us know that we are
commiting with enabled linking.
Only if we remember setting it back/disbale linking (or let ourselves remember by the git hook) the CI will work.
#### Summary
- We will always run into conflicts with the `pnpmfileChecksum` because in renovate prs it will be empty (`--ignore-pnpmfile`)
- To keep it simple we set `--ignore-pnpmfile` in all of our CI builds to see issues immediately.
- The only solution is to never have a `.pnpmfile.cjs` in the repository when pushing.
- This way there will never be a commit with `pnpmfileChecksum` in the lockfile.
- renovate (which uses `--ignore-pnpmfile` which we cannot disable) and other CI will work.
- We are able to use the linking system locally if we `cp` this file from the scripts folder into `./` on demand.
-`pnpm links:on` and `pnpm links:off` + `./scripts/setup-linking.sh` will help us with this.
| [Full](./embedded-standalone.md) | All | `https://element_call.domain/room` |
| [Embedded](./embedded-standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
| [Embedded](./embedded-standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
| [Full](./embedded_standalone.md) | All | `https://element_call.domain/room` |
| [Embedded](./embedded_standalone.md) | Remote URL | `https://element_call.domain/` n.b. no `/room` part |
| [Embedded](./embedded_standalone.md) | Embedded within messenger app | Platform dependent, but you load the `index.html` file without a `/room` part |
## Parameters
### Common Parameters
These parameters are relevant to both [widget](./embedded-standalone.md) and [standalone](./embedded-standalone.md) modes:
These parameters are relevant to both [widget](./embedded_standalone.md) and [standalone](./embedded_standalone.md) modes:
| Name | Values | Required for widget | Required for SPA | Description |
| `intent` |`start_call`, `join_existing`, `start_call_dm`,`join_existing_dm. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback`| `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
| `analyticsID` (deprecated: use `posthogUserId` instead) | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `appPrompt` | `true` or `false` | No, defaults to `true` | No, defaults to `true`| Prompts the user to launch the native mobile app upon entering a room, applicable only on Android and iOS, and must be enabled in config. |
| `confineToRoom`| `true` or `false` | No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
| `displayName` | | No | No | Display name used for auto-registration. |
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9`| No, defaults to `1.0` | No, defaults to `1.0` | Factor by which to scale the interface's font size. |
| `fonts`| | No | No | Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `header`| `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
| `hideScreensharing` | `true` or `false`| No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` || Not applicable | No | Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `lang` | [BCP 47](https://www.rfc-editor.org/info/bcp47) code| No | No | The language the app should use. |
| `password`| | No | No | E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE`| `true` or `false` | No, defaults to `false` | No, defaults to `false`| Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids)| Yes | No | 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 roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls` | `true` or `false`| No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false` | No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast`| No, defaults to `dark` | No, defaults to `dark`| UI theme to use. |
| `viaServers`| Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
| `sendNotificationType`| `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeaveWhenOthersLeft` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup`| `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
| Name | Values | Required for widget | Required for SPA | Description |
| `intent`| `start_call`, `join_existing`, `start_call_voice`, `join_existing_voice`,`start_call_dm`, `join_existing_dm`, `start_call_dm_voice`, or`join_existing_dm_voice`. | No, defaults to `start_call` | No, defaults to `start_call` | The intent is a special url parameter that defines the defaults for all the other parameters. In most cases it should be enough to only set the intent to setup element-call. |
| `allowIceFallback` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Allows use of fallback STUN servers for ICE if the user's homeserver doesn’t provide any. |
| `posthogUserId` | Posthog analytics ID | No | No | Available only with user's consent for sharing telemetry in Element Web. |
| `confineToRoom` | `true` or `false`| No, defaults to `false` | No, defaults to `false` | Keeps the user confined to the current call/room. |
| `displayName` | | No | No | Display name used for auto-registration. |
| `enableE2EE` (deprecated) | `true` or `false` | No, defaults to `true` | No, defaults to `true` | Legacy flag to enable end-to-end encryption, not used in the `livekit` branch. |
| `fontScale` | A decimal number such as `0.9` | No, defaults to `1.0` | No, defaults to `1.0`| Factor by which to scale the interface's font size. |
| `fonts` | | No| No| Defines the font(s) used by the interface. Multiple font parameters can be specified: `?font=font-one&font=font-two...`. |
| `header`| `none`, `standard` or `app_bar` | No, defaults to `standard` | No, defaults to `standard` | The style of headers to show. `standard` is the default arrangement, `none` hides the header entirely, and `app_bar` produces a header with a back button like you might see in mobile apps. The callback for the back button is `window.controls.onBackButtonPressed`. |
| `hideScreensharing` | `true` or `false`| No, defaults to `false` | No, defaults to `false` | Hides the screen-sharing button. |
| `homeserver` | | Not applicable | No| Homeserver for registering a new (guest) user, configures non-default guest user server when creating a spa link. |
| `lang`| [BCP 47](https://www.rfc-editor.org/info/bcp47) code | No | No | The language the app should use. |
| `password` | | No | No| E2EE password when using a shared secret. (For individual sender keys in embedded mode this is not required.) |
| `perParticipantE2EE`| `true` or `false` | No, defaults to `false` | No, defaults to `false` | Enables per participant encryption with Keys exchanged over encrypted matrix room messages. |
| `controlledAudioDevices` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the [global JS controls for audio devices](./controls.md#audio-devices) should be enabled, allowing the list of audio devices to be controlled by the app hosting Element Call. |
| `roomId` | [Matrix Room ID](https://spec.matrix.org/v1.12/appendices/#room-ids) | Yes | No| 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 roomId is an exception as we need the room ID in embedded widget mode, and not the room alias (or even the via params because we are not trying to join it). This is also not validated, where it is in `useRoomIdentifier()`. |
| `showControls`| `true` or `false` | No, defaults to `true` | No, defaults to `true` | Displays controls like mute, screen-share, invite, and hangup buttons during a call. |
| `skipLobby` (deprecated: use `intent` instead) | `true` or `false`| No. If `intent` is explicitly `start_call` then defaults to `true`. Otherwise defaults to `false` | No, defaults to `false` | Skips the lobby to join a call directly, can be combined with preload in widget. When `true` the audio and video inputs will be muted by default. (This means there currently is no way to start without muted video if one wants to skip the lobby. Also not in widget mode.) |
| `theme` | One of: `light`, `dark`, `light-high-contrast`, `dark-high-contrast` | No, defaults to `dark` | No, defaults to `dark` | UI theme to use. |
| `background` | One of: `solid`, `gradient` | No, defaults to `gradient` | No, defaults to `gradient` | Visual style of the page background. |
| `viaServers` | Comma separated list of [Matrix Server Names](https://spec.matrix.org/v1.12/appendices/#server-name) | Not applicable | No | Homeserver for joining a room, non-empty value required for rooms not on the user’s default homeserver. |
| `sendNotificationType` | `ring` or `notification` | No | No | Will send a "ring" or "notification" `m.rtc.notification` event if the user is the first one in the call. |
| `autoLeave` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | Whether the app should automatically leave the call when there is no one left in the call. |
| `waitForCallPickup` | `true` or `false` | No, defaults to `false` | No, defaults to `false` | When sending a notification, show UI that the app is awaiting an answer, play a dial tone, and (in widget mode) auto-close the widget once the notification expires. |
### Widget-only parameters
These parameters are only supported in [widget](./embedded-standalone.md) mode.
These parameters are only supported in [widget](./embedded_standalone.md) mode.
| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. |
| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. |
| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (in case the widget is not in an Iframe but in a dedicated webview we send the postMessages same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself) |
| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter |
| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. |
| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. |
| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. |
| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. |
| `baseUrl` | | Yes | The base URL of the homeserver to use for media lookups. |
| `deviceId` | Matrix device ID | Yes | The Matrix device ID for the widget host. |
| `parentUrl` | | Yes | The url used to send widget action postMessages. This should be the domain of the client or the webview the widget is hosted in. (In case the widget is not in an Iframe but in a dedicated webview, we send the postMessages in the same WebView the widget lives in. Filtering is done in the widget so it ignores the messages it receives from itself.) |
| `posthogUserId` | Posthog user identifier | No | This replaces the `analyticsID` parameter |
| `preload` | `true` or `false` | No, defaults to `false` | Pauses app before joining a call until an `io.element.join` widget action is seen, allowing preloading. |
| `returnToLobby` | `true` or `false` | No, defaults to `false` | Displays the lobby in widget mode after leaving a call; shows a blank page if set to `false`. Useful for video rooms. |
| `userId` | [Matrix User Identifier](https://spec.matrix.org/v1.12/appendices/#user-identifiers) | Yes | The Matrix user ID. |
| `widgetId` | [MSC2774](https://github.com/matrix-org/matrix-spec-proposals/pull/2774) format widget ID | Yes | The id used by the widget. The presence of this parameter implies that element call will not connect to a homeserver directly and instead tries to establish postMessage communication via the `parentUrl`. |
### Embedded-only parameters
These parameters are only supported in the [embedded](./embedded-standalone.md) package of Element Call and will be ignored in the [full](./embedded-standalone.md) package.
These parameters are only supported in the [embedded](./embedded_standalone.md) package of Element Call and will be ignored in the [full](./embedded_standalone.md) package.
* Node types that introduce a new non-module scope. A getChild() call nested
* inside any of these is considered "not at the top level".
*/
constFUNCTION_OR_CLASS_TYPES=newSet([
"FunctionDeclaration",
"FunctionExpression",
"ArrowFunctionExpression",
"ClassBody",
]);
construle=ESLintUtils.RuleCreator(
()=>"https://github.com/element-hq/element-call",
)({
name:"no-top-level-logger-get-child",
meta:{
type:"problem",
docs:{
description:
"Disallow calling logger.getChild() at the top level of a module."+
"`getChild` has to be called after the rageshake logger `init()`."+
"If it is called at the top level the child logger will never be setup for rageshakes.",
},
messages:{
noTopLevelGetChild:
"Do not call logger.getChild() at the top level of a module; move it inside a function or class instead that gets called after rageshake logger `init()` is called.",
},
schema:[],
},
create(context){
// Tracks the local binding names that refer to the logger imported from
// 'matrix-js-sdk/lib/logger', e.g. both `logger` and `rootLogger` in:
// import { logger } from "matrix-js-sdk/lib/logger";
// import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.