Compare commits

..
Author SHA1 Message Date
fkwpandClaude Opus 5.5 993991105f Keep five backgrounds of their own, which fills the grid
- 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>
2026-09-23 13:09:17 +02:00
fkwpandClaude Opus 5.5 f2782c565e Add a background without putting it on, before joining too
- 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>
2026-09-23 13:07:17 +02:00
fkwpandClaude Opus 5.5 fd972a717e Show that the camera menu scrolls, whatever the scrollbar does
- 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>
2026-09-23 12:51:41 +02:00
fkwpandClaude Opus 5.5 b7ca586f78 Keep the headings over the grid, and the preview where there is no room
- 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>
2026-09-23 12:07:46 +02:00
fkwpandClaude Opus 5.5 efe6274636 Keep the menu's frame visible beside a scrolling list in Safari
- 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>
2026-09-23 10:30:40 +02:00
fkwpandClaude Opus 5.5 8d95c76b8b Hold the camera menu at 296px, as design sets it
- 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>
2026-09-23 10:29:56 +02:00
fkwpandClaude Opus 5.5 31f057726f Fill the preview's width in Safari
- 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>
2026-09-23 09:58:42 +02:00
fkwpandClaude Opus 5.5 45469f3828 Show how the user looks while they choose a background
- 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>
2026-09-23 09:35:57 +02:00
fkwpandClaude Opus 5 8ffc8e2720 Sit the notice as far from the menu's foot as from its sides
- 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>
2026-09-18 15:04:40 +02:00
fkwpandClaude Opus 5 508fde1029 Say what the browser costs without raising its voice
- 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>
2026-09-18 14:59:25 +02:00
fkwpandClaude Opus 5 a2fc94a882 Guard that a shipped background can cover something
- 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>
2026-09-18 14:10:26 +02:00
fkwpandClaude Opus 5 dfd42e2edd Give a background something to cover with
- 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>
2026-09-18 13:59:07 +02:00
fkwpandClaude Opus 5 bfc42d8d7f Lay the background effect states out for review
- 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>
2026-09-18 13:49:40 +02:00
fkwpandClaude Opus 5 5b8e7d8cfd Offer effects wherever they run, and say what they cost
- 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>
2026-09-18 13:49:40 +02:00
fkwpandClaude Opus 5 2f214c8ca2 Ask the bench where the startup went
- 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>
2026-09-18 11:52:31 +02:00
fkwpandClaude Opus 5 6a4bfa1b9a Put the bench's buckets on the clock
- 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>
2026-09-18 11:49:07 +02:00
fkwpandClaude Opus 5 1e445a4d14 Measure whether a device can run background effects
- 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>
2026-09-18 11:35:42 +02:00
fkwpandClaude Opus 5 aabd9a7022 Ask one question about whether effects can run
- 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>
2026-09-18 11:14:44 +02:00
fkwpandClaude Opus 5 1299dad4b1 Draw the remove button as design drew it
- 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>
2026-09-18 10:58:13 +02:00
fkwpandClaude Opus 5 4cea64bd07 Format the branch
- 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>
2026-09-17 23:24:12 +02:00
fkwpandClaude Opus 5 61cc12d937 Take the section rules from the device menu's new design
- 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>
2026-09-17 23:12:29 +02:00
fkwpandClaude Opus 5 c793cd5fc1 Open up the space between the effect tiles
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 8a2ad2226d Move the remove cross to the tile's corner
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 c7c44eea4e Put an added background on before joining, not during a call
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 ea25cde725 Add a background without putting it on
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 31e67577b0 Keep the refusal out of the scrolling list
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 72ad732771 Space the refusal evenly, and let it go when the menu does
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 fb223bcc4b Say why a file cannot be used as a background
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 3ac42a735b Make the cross remove by mouse, not just by keyboard
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 e3aaad8615 Let a user remove a background they added
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 943cae0129 Sit the grid evenly in the menu, and the headings closer to their rules
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 79eb5c8606 Draw the active frame at one width on every tile
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 35f9893b0a Even up the space below the two section rules
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 304373e735 Rule off under each heading in the camera menu
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 1af968748b Keep the menu open when adding a background, and choose it
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 81fe5d355f Let a user add a background of their own
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 8a7eb5c7fd Make the settings blur control agree with the camera menu
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 6a6605c233 Let background effects run under the dev server
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 9179b1cccb Wire the background effects grid to the pipeline
- 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>
2026-09-17 23:01:54 +02:00
fkwpandClaude Opus 5 e823fbc85b Show background effects as a grid in the camera menu
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>
2026-09-17 23:01:53 +02:00
fkwpandClaude Opus 5 b90f267009 Attach background effects through one switchable pipeline
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>
2026-09-17 23:01:53 +02:00
fkwpandClaude Opus 5 5fff49670b Expect the default output entry to carry no name
- 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>
2026-09-17 22:52:08 +02:00
fkwp 0633b24880 Merge remote-tracking branch 'origin/main' into fkwp/explore/quick-audio-menu 2026-09-17 22:50:39 +02:00
fkwpandClaude Opus 5 099950a100 Update the device menu to the latest design
- 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>
2026-09-17 22:43:48 +02:00
Timo 03f81bcd64 Merge pull request #4266 from toger5/toger5/memberId-based-on-matrixRTCMode
Make the memberId dependent on the matrixRTC mode.
2026-09-17 14:02:32 +02:00
Timo K. 9f6c8bb434 Make the memberId dependent on the matrixRTC mode.
This outherwise creates to-device messages with the uuid for members
that use the userId:deviceId memberId in the state event.
2026-09-17 13:44:25 +02:00
fkwp 194bfea287 Cover the speaker section sitting above the microphone section
- 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.
2026-09-17 11:56:31 +02:00
fkwp 4f8f71e861 Cover the device menu with stories and end-to-end specs
- 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.
2026-09-17 11:56:31 +02:00
fkwp d7e0444117 Write the mute button once instead of twice per kind
- 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.
2026-09-17 11:56:31 +02:00
fkwp a6e00895a0 Track the focus modality on the menu itself
- 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.
2026-09-17 11:56:31 +02:00
fkwp 8f3506782a Keep each section's heading in view while the list scrolls
- 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.
2026-09-17 11:56:31 +02:00
fkwp 8508d88714 Size the device list to the call as it changes
- 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.
2026-09-17 11:56:31 +02:00
fkwp ace7e947b4 Show a default speaker where the platform lists none
- 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.
2026-09-17 11:56:31 +02:00
fkwp 6c3012a40b Line the level meter up with the device rows
- 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.
2026-09-17 11:56:31 +02:00
fkwp 6c9550da0a Give the level meter one bar size at every width
- 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.
2026-09-17 11:56:31 +02:00
fkwp d8211c0c61 Own the microphone level in the state layer
- 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.
2026-09-17 11:56:25 +02:00
renovate[bot] f56c74adf9 Update ghcr.io/element-hq/element-web:develop Docker digest to 3993d53 (#4265)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-17 00:51:42 +00:00
fkwp 0802649375 Show the focus ring for the keyboard alone, and fix the radio styling
- 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
2026-09-16 17:44:55 +02:00
fkwp a76ef6215b more spacing between player and mic section 2026-09-16 16:27:17 +02:00
fkwp 7f3b98ed6a Size the device menu against the call, and cover its criteria
- 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
2026-09-16 16:23:02 +02:00
Johannes Marbach 3c88d217f2 Merge pull request #4244 from element-hq/matthew/default-output-first
Prefer the browser default audio output when no output has been chosen
2026-09-16 15:39:12 +02:00
fkwp 3f97ec8619 Show a live microphone level in the audio menu
- 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
2026-09-16 15:37:05 +02:00
fkwp 9a68de193f Show a live microphone level in the audio menu
- 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
2026-09-16 14:52:59 +02:00
fkwp 664fac104f Use radio controls to mark the selected device in the quick menu
- 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
2026-09-16 14:02:01 +02:00
fkwp ef2cbbbaea Translate the device menu titles and drop the duplicated heading
Spec: FEATURES_SPEC/2026-09_Quick_Audio_Menu.md — AC1
2026-09-16 13:14:23 +02:00
fkwp 27789131ee Add speaker selection to the microphone quick menu
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
2026-09-16 12:58:32 +02:00
renovate[bot] 967a06ba39 Update ghcr.io/element-hq/element-web:develop Docker digest to 506caaa (#4262)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-16 00:29:13 +00:00
renovate[bot] 71593f1a24 Update ghcr.io/element-hq/element-web:develop Docker digest to c76d299 (#4260)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-15 02:40:31 +00:00
renovate[bot]andJohannes Marbach 5d38a13c46 Update ghcr.io/element-hq/element-web:develop Docker digest to 9d8d8fc (#4243)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 1850bf6

* Adapt to latest EW

* Reformat

* Next try

* Fix regexp

* Fix DM path

* Next try

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-09-14 08:51:33 +00:00
Matthew Hodgson 426acd222e Prefer the browser default audio output when no output has been chosen
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.
2026-09-08 23:22:44 +01:00
162 changed files with 7993 additions and 11935 deletions
+11 -15
View File
@@ -14,13 +14,17 @@ on:
description: The build mode for vite. Must be either 'development' or 'production'
required: false
default: production
# No `secrets:` block: every caller passes `secrets: inherit`, so the
# repository's secrets reach this workflow without each one being declared
# here and threaded through by name at the call sites -- the same pattern
# build-and-publish-docker.yaml uses. A secret this workflow starts using
# then needs no change to the callers. The ones read below are
# MATRIX_RTC_NPM_TOKEN, SENTRY_ORG, SENTRY_PROJECT, SENTRY_URL,
# SENTRY_AUTH_TOKEN and CODECOV_TOKEN.
secrets:
SENTRY_ORG:
required: true
SENTRY_PROJECT:
required: true
SENTRY_URL:
required: true
SENTRY_AUTH_TOKEN:
required: true
CODECOV_TOKEN:
required: false
jobs:
build:
@@ -38,14 +42,6 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
+18 -11
View File
@@ -15,7 +15,12 @@ jobs:
package: full
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets: inherit
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
deploy_develop:
# Deploy main branch to call.element.dev after build completes
if: github.ref == 'refs/heads/main'
@@ -60,7 +65,12 @@ jobs:
package: embedded
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets: inherit
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_sdk_element_call:
# Use the embedded package vite build
@@ -69,7 +79,12 @@ jobs:
package: sdk
vite_app_version: ${{ github.event.release.tag_name || github.sha }}
build_mode: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'development build') && 'development' || 'production' }}
secrets: inherit
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_storybook:
name: Build Storybook
@@ -87,14 +102,6 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
- name: Build Storybook
-8
View File
@@ -17,14 +17,6 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
@@ -66,7 +66,11 @@ jobs:
with:
vite_app_version: embedded-${{ needs.versioning.outputs.PREFIXED_VERSION }}
package: embedded
secrets: inherit
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
publish_tarball:
needs: [build_element_call, versioning]
+5 -1
View File
@@ -13,7 +13,11 @@ jobs:
with:
package: full
vite_app_version: ${{ github.event.release.tag_name }} # Using ${{ env.VERSION }} here doesn't work
secrets: inherit
secrets:
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
SENTRY_URL: ${{ secrets.SENTRY_URL }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
publish_tarball:
needs: build_element_call
+2 -27
View File
@@ -19,14 +19,6 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
@@ -52,17 +44,9 @@ jobs:
flags: unittests
fail_ci_if_error: true
playwright:
name: Run end-to-end tests (${{ matrix.call_implementation }})
name: Run end-to-end tests
timeout-minutes: 60
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Both MatrixRTC implementations, until matrix-rtc becomes the
# default and matrix-js-sdk goes (oxidation plan §5.15). The
# matrix-rtc run does not block until it has passed once.
call_implementation: [matrix-js-sdk, matrix-rtc]
continue-on-error: ${{ matrix.call_implementation == 'matrix-rtc' }}
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
@@ -73,14 +57,6 @@ jobs:
with:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install dependencies
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: pnpm install --frozen-lockfile --ignore-pnpmfile
@@ -104,12 +80,11 @@ jobs:
- name: Run Playwright tests
env:
USE_DOCKER: 1
CALL_VIEW_MODEL_IMPLEMENTATION: ${{ matrix.call_implementation }}
run: pnpm exec playwright test
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ !cancelled() }}
with:
name: html-report-${{ matrix.call_implementation }}
name: html-report
path: playwright-report
if-no-files-found: error
retention-days: 4
@@ -25,14 +25,6 @@ jobs:
cache: "pnpm"
node-version-file: ".node-version"
- name: Authenticate to GitHub Packages
# The MatrixRTC SDK (@billcarsonfr/matrix-rtc, see .npmrc) is on the GitHub
# Packages npm registry, which needs a token with read:packages even for
# public packages. A workflow's GITHUB_TOKEN only reaches packages of its own
# repository, so this is a personal access token kept as a repository secret.
env:
NPM_REGISTRY_TOKEN: ${{ secrets.MATRIX_RTC_NPM_TOKEN }}
run: echo "//npm.pkg.github.com/:_authToken=${NPM_REGISTRY_TOKEN}" >> ~/.npmrc
- name: Install Deps
# ignore-pnpmfile should never be commited. Make CI crash if it happened (`pnpmfileChecksum` is present)
run: "pnpm install --frozen-lockfile --ignore-pnpmfile"
-9
View File
@@ -1,9 +0,0 @@
# The MatrixRTC SDK (`@element-hq/matrix-rtc`, aliased in package.json to the
# name the fork's CI publishes it under) comes from the GitHub Packages npm
# registry, which needs a token with `read:packages` even for public
# packages. This is a stop-gap while the SDK is in flux; once it is published
# to npmjs.com this file and the alias go away (docs/matrix_rtc_sdk.md).
# Only the scope->registry mapping lives here; the token goes in your
# user-level npm config (~/.npmrc), never in this file. CI writes it there
# from a repository secret (see .github/workflows).
@billcarsonfr:registry=https://npm.pkg.github.com
-5
View File
@@ -10,7 +10,6 @@ import { TooltipProvider } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/lib/logger";
import EN from "../locales/en/app.json";
import { Config } from "../src/config/Config";
import { initReactI18next } from "react-i18next";
import { i18n } from "../src/utils/i18n";
import "../src/index.css";
@@ -36,10 +35,6 @@ i18n
})
.catch((e) => logger.warn("Failed to init i18n for stories", e));
// Stories run without a config.json; the defaults stand in, as they do for
// a component host that passes none.
Config.initWith({});
const preview: Preview = {
parameters: {
layout: "centered",
+1 -17
View File
@@ -206,19 +206,6 @@ corepack enable
pnpm install
```
`pnpm install` currently needs a GitHub token: the MatrixRTC SDK
(`@element-hq/matrix-rtc`) is, as a stop-gap until it is published to npmjs.com,
an npm package on the GitHub Packages registry, which rejects anonymous
requests even for public packages. Create a classic personal access token with
the `read:packages` scope and add it to your user-level npm config once:
```sh
echo "//npm.pkg.github.com/:_authToken=<your token>" >> ~/.npmrc
```
See [The MatrixRTC SDK package](./docs/matrix_rtc_sdk.md) for the details, CI
setup and developing against a local crate.
To use it, create a local config by, e.g.,
`cp ./config/config.devenv.json ./public/config.json` and adapt it if necessary.
The `config.devenv.json` config should work with the backend development
@@ -233,7 +220,6 @@ pnpm dev
See also:
- [Developing with linked packages](./docs/linking.md)
- [The MatrixRTC SDK package](./docs/matrix_rtc_sdk.md)
#### Element Call as a component (experimental)
@@ -312,9 +298,7 @@ The package is not published yet. A host installs it as a git dependency on the
whose `prepare` script runs the build on install. That build needs pnpm (via
Corepack) on the host's machine, runs a full `pnpm install` of this repository
(so the host needs the GitHub Packages token described in
[The MatrixRTC SDK package](./docs/matrix_rtc_sdk.md) as well) and is
memory-hungry, since it inherits the `--max-old-space-size` setting of
and is memory-hungry, since it inherits the `--max-old-space-size` setting of
the app build; the host's pnpm also has to allow it to run at all
(`allowBuilds` in its `pnpm-workspace.yaml`). Note that `component/` is a pnpm
project of its own for this reason, so pnpm commands run from inside that
+2 -2
View File
@@ -18,7 +18,7 @@ import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ElementCallClientBased,
ElementCall,
type ElementCallHandle,
supportedLanguages,
} from "../index";
@@ -160,7 +160,7 @@ const Pane: FC<{
one of the things we cannot find out from the standalone app */}
<div className={styles.paneCall} data-testid="call-container">
{mounted && (
<ElementCallClientBased
<ElementCall
ref={handle}
client={session.client}
roomId={roomId}
+24 -115
View File
@@ -43,7 +43,7 @@ import {
useRef,
useState,
} from "react";
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -56,6 +56,7 @@ import LanguageDetector from "i18next-browser-languagedetector";
import EN from "../locales/en/app.json";
import { CallView } from "../src/room/CallView";
import { ErrorPage } from "../src/FullScreenView";
import { ClientProvider } from "../src/ClientContext";
import { HostBridgeProvider } from "../src/HostBridge";
import { RootElementProvider, useRootElement } from "../src/RootElementContext";
import {
@@ -77,17 +78,6 @@ import { type ConfigOptions } from "../src/config/ConfigOptions";
import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
import { type ElementCallMatrixClientDriver } from "../src/driver/ElementCallMatrixClientDriver";
import { useJsSdkDrivers } from "../src/driver/jsSdk/useJsSdkDrivers";
import {
initMatrixRtcSdk,
type MatrixRtcWasmSource,
} from "../src/matrix-rtc-sdk";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../src/driver/MatrixDriverContext";
import styles from "./ElementCall.module.css";
import {
type ElementCallHandle,
@@ -99,20 +89,6 @@ import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// What a host implements to drive Element Call with its own Matrix stack, and
// the implementations of both over a matrix-js-sdk client
export { type RtcMatrixDriver } from "../src/driver/RtcMatrixDriver";
export {
type ElementCallMatrixClientDriver,
type MatrixClientFeatures,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type OwnProfile,
} from "../src/driver/ElementCallMatrixClientDriver";
export { JsSdkRtcMatrixDriver } from "../src/driver/jsSdk/JsSdkRtcMatrixDriver";
export { JsSdkElementCallMatrixClientDriver } from "../src/driver/jsSdk/JsSdkElementCallMatrixClientDriver";
// How the host and Element Call talk to each other, and what they say
export { type ElementCallHandle, type ElementCallHostBridge } from "./host";
export {
@@ -148,25 +124,12 @@ export type ElementCallConfiguration = Partial<UrlConfiguration> &
export interface ElementCallProps {
/**
* The MatrixRTC side of the host's Matrix stack, bound to the room to call
* in: what the crate needs to publish a membership, exchange keys and mint
* transport tokens. Element Call does not authenticate anyone or manage a
* session of its own; both drivers are the host's.
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
rtcDriver: RtcMatrixDriver;
/**
* Everything else Element Call asks of a Matrix client for that room: who
* is in it, its name and avatar, the timeline for reactions and
* notifications, the user's own profile.
*/
clientDriver: ElementCallMatrixClientDriver;
/**
* The room to call in. Optional, and when given it must be the room the
* drivers are bound to (`clientDriver.roomId`). Kept for one release so
* that hosts written for the client-based component can move over one
* prop at a time; then it goes.
*/
roomId?: string;
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
/**
* What the user asked for — whether they started the call or joined one that
* was already running, and whether it is a call in a group or a DM. Element
@@ -213,48 +176,16 @@ export interface ElementCallProps {
language?: string;
}
/**
* {@link ElementCall} for a host with a matrix-js-sdk client: the two drivers
* are built here from the client and the room, so the host hands over the
* client as it always has.
*/
export type ElementCallClientBasedProps = Omit<
ElementCallProps,
"rtcDriver" | "clientDriver" | "roomId"
> & {
/**
* The client to place the call with. Element Call does not authenticate
* anyone or manage a session of its own; this one is the host's.
*/
client: MatrixClient;
/** The room to call in. The host's client must already know about it. */
roomId: string;
};
/**
* Prepares the things Element Call needs before it can be shown: translations,
* `Intl` polyfills for older browsers, and its configuration.
*
* Await this once, before rendering {@link ElementCall}.
*/
export interface InitializeElementCallOptions {
/**
* Where to load the MatrixRTC SDK's wasm from, for a host that serves it
* from somewhere other than next to this bundle. Left out, the bundled
* copy is loaded when the first call needs it.
*/
matrixRtcWasm?: MatrixRtcWasmSource;
}
export async function initializeElementCall(
config: ConfigOptions = {},
{ matrixRtcWasm }: InitializeElementCallOptions = {},
): Promise<void> {
const polyfills: Promise<unknown>[] = [];
// The call runs on the Rust MatrixRTC crate; its wasm is loaded up front
// when the host says where from, and lazily otherwise.
if (matrixRtcWasm !== undefined)
polyfills.push(initMatrixRtcSdk(matrixRtcWasm));
if (shouldPolyfillSegmenter())
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
if (shouldPolyfillDurationFormat())
@@ -299,9 +230,8 @@ const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
};
export const ElementCall: FC<ElementCallProps> = ({
rtcDriver,
clientDriver,
roomId: suppliedRoomId,
client,
roomId,
intent = UserIntent.JoinExistingCall,
config,
hostBridge: suppliedHostBridge,
@@ -310,11 +240,6 @@ export const ElementCall: FC<ElementCallProps> = ({
language,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
const roomId = clientDriver.roomId;
if (suppliedRoomId !== undefined && suppliedRoomId !== roomId)
throw new Error(
`Element Call was asked to call in ${suppliedRoomId} with drivers bound to ${roomId}`,
);
useEffect(() => {
if (language !== undefined)
@@ -363,19 +288,24 @@ export const ElementCall: FC<ElementCallProps> = ({
};
}, [controlledAudioDevices, callIntent]);
// The call runs on the Rust MatrixRTC crate over these two drivers; no
// matrix-js-sdk client is involved.
const drivers = useMemo(
(): MatrixDrivers => ({ rtcDriver, clientDriver }),
[rtcDriver, clientDriver],
const room = client.getRoom(roomId);
const rtcSession = useMemo(
() => (room === null ? null : client.matrixRTC.getRoomSession(room)),
[client, room],
);
if (rtcSession === null)
logger.error(
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
);
// Everything the call needs is in hand once these exist, and the first
// render with them is where the call itself appears: the moment the host
// is told that Element Call has loaded, as the widget tells its client once
// its own initialisation is over. Once per mount, however often the pieces
// are later swapped out.
const ready = container !== null && mediaDevices !== null;
const ready =
container !== null && rtcSession !== null && mediaDevices !== null;
const announcedLoaded = useRef(false);
useEffect(() => {
if (!ready || announcedLoaded.current) return;
@@ -401,10 +331,12 @@ export const ElementCall: FC<ElementCallProps> = ({
>
<Decoration>
<TooltipProvider>
<MatrixDriverProvider value={drivers}>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
@@ -412,7 +344,7 @@ export const ElementCall: FC<ElementCallProps> = ({
/>
</ProcessorProvider>
</MediaDevicesContext>
</MatrixDriverProvider>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
@@ -424,26 +356,3 @@ export const ElementCall: FC<ElementCallProps> = ({
</I18nextProvider>
);
};
export const ElementCallClientBased: FC<ElementCallClientBasedProps> = ({
client,
roomId,
...props
}): ReactNode => {
const room = client.getRoom(roomId);
if (room === null) {
logger.error(
`Element Call was asked to call in ${roomId}, which its host's client does not know about`,
);
return null;
}
return <ClientBasedCall client={client} room={room} {...props} />;
};
/** {@link ElementCallClientBased} once the room is known: builds the drivers. */
const ClientBasedCall: FC<
Omit<ElementCallClientBasedProps, "roomId"> & { room: Room }
> = ({ client, room, ...props }): ReactNode => {
const drivers = useJsSdkDrivers(client, room);
return <ElementCall {...props} {...drivers} />;
};
+2 -2
View File
@@ -19,7 +19,7 @@ services:
- ./backend/playwright_homeserver-othersite.yaml:/data/cfg/homeserver.yaml:Z
element-web:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
element-web-1:
# Pin to a SHA so that upstream cannot break our tests. Renovate handles regular updates.
image: ghcr.io/element-hq/element-web:develop@sha256:6ed62b2963ae098a1f79521eb516834dcf29452168e281cc359d9cfb4380adf6
image: ghcr.io/element-hq/element-web:develop@sha256:3993d53bc2c784c0376b86e00230aef0ee2c9a705b2166e9229555117e8db167
-1
View File
@@ -9,7 +9,6 @@ This folder contains documentation for setup, usage, and development of Element
- [MatrixRTC modes](./matrix_rtc_modes.md)
- [Self-Hosting](./self_hosting.md)
- [Developing with linked packages](./linking.md)
- [The MatrixRTC SDK package](./matrix_rtc_sdk.md) (registry token, CI, local crate)
### Writing code for Element Call (agent-readable conventions)
-3
View File
@@ -29,9 +29,6 @@ Instead, create a file named `.links.cjs` in the Element Call project directory
module.exports = {
"matrix-js-sdk": "../your/path/matrix-js-sdk",
"matrix-widget-api": "../your/path/matrix-widget-api",
// The MatrixRTC SDK: run `npm run build` in that directory first
"@element-hq/matrix-rtc":
"../your/path/matrix-rust-rtc/MatrixSdkArchitectureDraft/web-test-app",
};
```
-135
View File
@@ -1,135 +0,0 @@
# The MatrixRTC SDK package
Element Call's MatrixRTC logic (session membership, keep-alive, transport
tokens, media key exchange) runs in the Rust `matrix-rtc` crate, compiled to
WebAssembly and shipped as the npm package `@element-hq/matrix-rtc`. The
package is built and published by the
[`npm-web-bindings.yml`](https://github.com/element-hq/matrix-rust-rtc/blob/main/.github/workflows/npm-web-bindings.yml)
workflow of the `matrix-rust-rtc` repository, from its
`MatrixSdkArchitectureDraft/web-test-app` directory. Element Call loads it in
`src/matrix-rtc-sdk/index.ts`, the only module that imports the package
directly.
## Where the package comes from
For now the package lives on the **GitHub Packages** npm registry
(`https://npm.pkg.github.com`), not on npmjs.com. **This is a stop-gap** for
the phase in which the crate's API still changes with every push: publishing
from the crate's CI into a registry that comes with the repository needs no
account or secret upstream, and nothing else consumes the package yet. Once
the SDK is ready for consumers beyond Element Call, it should be published to
**npmjs.com** as `@element-hq/matrix-rtc`, which makes everything in this
document about tokens and scopes go away: `pnpm install` works anonymously
again, forks and Renovate can install, component hosts need no setup, and the
alias in `package.json` and the scope line in `.npmrc` are deleted.
Until then, two properties of GitHub Packages shape the setup below:
- **Every request needs a token**, including reads of public packages. There
is no anonymous install.
- **A package is scoped to the owner of the repository whose CI published
it.** While the crate is developed on the `BillCarsonFr` fork, the package is
published as `@billcarsonfr/matrix-rtc`; once it is published from
`element-hq/matrix-rust-rtc` it becomes `@element-hq/matrix-rtc`.
So that the code does not have to change when that happens, `package.json`
installs the fork's package under the final name:
```json
"@element-hq/matrix-rtc": "npm:@billcarsonfr/matrix-rtc@next"
```
and the repository's `.npmrc` maps only the scope to the registry:
```ini
@billcarsonfr:registry=https://npm.pkg.github.com
```
When the package moves to `element-hq`, change the alias to the plain package
and the scope line in `.npmrc` accordingly; nothing else in the repository
refers to the fork's name.
## Setting up your machine
1. Create a **personal access token (classic)** at
<https://github.com/settings/tokens> with only the `read:packages` scope.
Fine-grained tokens do not work with GitHub Packages.
2. Add it to your **user-level** npm config. Never put it in the repository's
`.npmrc`:
```sh
echo "//npm.pkg.github.com/:_authToken=<your token>" >> ~/.npmrc
```
3. Run `pnpm install` as usual.
Without the token, `pnpm install` fails while resolving
`@billcarsonfr/matrix-rtc` with a `401` or `404` from `npm.pkg.github.com`;
that error always means "no or wrong token", never "the package does not
exist".
## CI and other consumers
A workflow's built-in `GITHUB_TOKEN` can only read packages owned by the
repository's own owner, so element-hq's workflows cannot read a package owned
by the `BillCarsonFr` user. The workflows that run `pnpm install` therefore
write a personal access token from the repository secret
`MATRIX_RTC_NPM_TOKEN` (scope `read:packages`) to `~/.npmrc` before installing.
Keep in mind:
- **Pull requests from forks and from Renovate do not receive secrets**, so
their installs fail until the package is readable with the built-in token.
- **Hosts of the [component](../README.md#element-call-as-a-component-experimental)**
install it as a git dependency whose `prepare` script runs
`pnpm install --frozen-lockfile` in this repository, so they need the same
token in their `~/.npmrc` or CI.
Publishing from `element-hq/matrix-rust-rtc` (and granting the Element Call
repository access to the package) would let the built-in token work for
element-hq's own workflows, but that only patches the CI case. The intended end
state is the npmjs.com publication described above, which removes the token
requirement for everyone.
## Versions
The package version tracks the crate's `Cargo.toml`. Every push to the tracked
branches of `matrix-rust-rtc` publishes a `next` pre-release named
`<version>-next.<run number>.<short sha>`, and a `matrix-rtc-v<version>` tag
publishes `latest`. Element Call follows `next`; `pnpm install` picks up a new
pre-release only when the lockfile is updated, e.g. with
`pnpm update @element-hq/matrix-rtc`.
The publish job runs only after the crate's own `cargo test` job passed on a
_push_ (pull-request runs never publish). If a `next` build you expect is
missing, check that workflow's push runs for a failed crate job and re-run it;
its tests are known to be timing-sensitive.
## Developing against a local crate checkout
To run Element Call against a crate you are changing, build the package there
and link it, instead of waiting for CI:
```sh
cd ../matrix-rust-rtc/MatrixSdkArchitectureDraft/web-test-app
npm install && npm run build # produces dist/
```
then add it to `.links.cjs` in this repository and run `pnpm links:on`, as
described in [Developing with linked packages](./linking.md):
```cjs
"@element-hq/matrix-rtc": "../matrix-rust-rtc/MatrixSdkArchitectureDraft/web-test-app",
```
`pnpm links:off` restores the registry package before committing.
## How the wasm is loaded
`initMatrixRtcSdk()` in `src/matrix-rtc-sdk/index.ts` is the single entry
point: it awaits the package's `initAsync`, then installs the log sink that
routes the crate's logs into Element Call's logger. The wasm is imported as
`@element-hq/matrix-rtc/wasm?url`, so the app builds emit it as an asset next
to the other `.wasm` files and the component build inlines it. Unit tests read
the bytes from the installed package instead (`src/utils/test-matrix-rtc.ts`),
since vitest has no server to fetch from. A component host can point at a copy
it serves itself through `initializeElementCall(config, { matrixRtcWasm })`.
-977
View File
@@ -1,977 +0,0 @@
# Element Call oxidation plan
Move Element Call's MatrixRTC participation logic off `matrix-js-sdk`'s
`MatrixRTCSession` and onto the Rust `matrix-rtc` crate
(`~/Projects/matrix-rust-rtc/MatrixSdkArchitectureDraft`), consumed through its
uniffi wasm bindings. The Element Call **component** then depends on one
host-supplied object, a `MatrixDriver`, and never on a `MatrixClient`. The
standalone app and the widget become hosts like any other: they build a
`MatrixDriver` from their `matrix-js-sdk` client and hand it to the component.
Revision 2 — incorporates the independent review (§10 lists what changed and
the assumptions taken where only the user can decide).
Status legend: ☐ todo · ◐ in progress · ☑ done.
**Where things stand (2026-09-14):** S0a, S0b, S1a, S1b and S2 are
implemented and green (`pnpm lint`, `pnpm format:check`, `pnpm test:unit`:
101 files / 785 tests). Nothing is committed yet, in either repository: the
crate changes C2–C12 (C1 reverted in favour of slot opening) are done; C2–C8,
C11 and C12 are committed in the draft repo as `a8e21b3`, C5 (final form), C9
and C10 are still uncommitted there. In Element Call everything sits
uncommitted in
`~/Projects/matrix-rust-rtc/MatrixSdkArchitectureDraft`, and Element Call's
branch `toger5/oxidation` holds the vendored bindings, the driver layer
(`src/driver/**`), the participation layer (`src/state/rtc/**`) and the
config/lint changes. S3a is the next slice; its design is in §6 and the
files it touches are listed there.
---
## 1. Goals and non-goals
**Goals**
1. `ElementCall` (component) takes `driver: MatrixDriver` instead of
`client: MatrixClient`. Nothing rendered under `CallView` imports
`MatrixClient`, `Room`, `RoomMember`, `MatrixEvent` or
`matrix-js-sdk/lib/matrixrtc`.
2. All MatrixRTC participation logic (session projection, own membership
join/leave/keep-alive, transport tokens, media key exchange) comes from the
crate's `FfiParticipationManager`. Element Call keeps only what the crate
deliberately leaves to the host: the LiveKit media plane, tiles, room
metadata, reactions, notifications, UI.
3. The standalone SPA, widget mode and `sdk/main.ts` construct a
`JsSdkMatrixDriver` (a port of the draft's `web-test-app/src/jsSdkDriver.ts`
that also works on js-sdk's `RoomWidgetClient`, extended with what Element
Call needs beyond RTC) and stop using `client.matrixRTC`.
4. Every existing gate stays green: `pnpm lint` (tsc, oxlint, knip, component
externals), `pnpm format:check`, `pnpm test` (unit + storybook),
`pnpm i18n:check`, all four builds, Playwright (standalone, widget,
component).
**Non-goals**
- Replacing `matrix-js-sdk` in the standalone shell (login, registration, room
creation, home page, crypto bootstrap). The shell keeps its client and wraps
it. `src/home/useGroupCallRooms.ts` stays on `client.matrixRTC` for now.
- Removing `matrix-js-sdk/lib/logger`. It is isolated behind
`src/utils/logger.ts` (S6) so a later swap is one line.
- Writing a matrix-rust-sdk-backed driver (Element X). The interface is shaped
so one can be written; none is written here.
- Publishing the crate as an npm package: done upstream (`@element-hq/matrix-rtc`,
built and published by `npm-web-bindings.yml` in matrix-rust-rtc; consumed
here since 2026-09-16, §5.1).
- Turning on MSC4153 (cross-signed sender) enforcement. Parity first (§5.8).
---
## 2. Where the code is today (inventory)
Entry: `component/index.tsx` → `src/room/CallView.tsx` → `LobbyView` |
`ActiveCall` (`src/room/InCallView.tsx`) | `CallEndedView`. `ActiveCall`
builds the view model with
`createCallViewModel$(scope, rtcSession, matrixRoom, mediaDevices, muteStates, options, raisedHands$, reactions$, trackProcessorState$)`.
| Concern | Files | js-sdk surface used |
| -------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Session memberships | `src/state/SessionBehaviors.ts`, `src/useMatrixRTCSessionMemberships.ts` | `rtcSession.memberships`, `MembershipsChanged`, `membership.getTransport`, `isKeyRotationSuppressed` |
| Own membership | `localMember/LocalMember.ts` (`enterRTCSession`), `localMember/HomeserverConnected.ts` | `joinRTCSession`, `leaveRoomSession(1000)`, `updateCallIntent`, `MembershipManagerEvent.*`, `ClientEvent.Sync`; delegation probe at `LocalMember.ts:298-313`, delegation through the JWT service at `:731-756` |
| SFU / JWT | `src/livekit/openIDSFU.ts`, `localMember/LocalTransport.ts`, `localMember/RtcTransportAutoDiscovery.ts`, `remoteMembers/Connection.ts`, `ConnectionFactory.ts`, `ConnectionManager.ts` | `getOpenIdToken`, `_unstable_getRTCTransports`, `POST /get_token` (+ `delay_id`, `delay_timeout`, `delay_cs_api_url`), legacy `/sfu/get` |
| Remote member ↔ LiveKit identity | `remoteMembers/MatrixLivekitMembers.ts` | `rtcBackendIdentity`, `userId`, `deviceId`, `memberId` |
| E2EE media keys | `src/e2ee/matrixKeyProvider.ts`, `src/e2ee/sharedKeyManagement.ts` | `EncryptionKeyChanged`, `reemitEncryptionKeys`, `room.hasEncryptionStateEvent` |
| Room metadata | `remoteMembers/MatrixMemberMetadata.ts`, `src/utils/displayname.ts`, `src/room/useRoomName.ts`, `useRoomState.ts`, `useRoomAvatar.ts`, `useJoinRule.ts`, `InviteModal.tsx`, `CallView.tsx` | `getMembersWithMembership`, `RoomStateEvent.Members`, `room.name`, `getMxcAvatarUrl`, `getJoinRule`, `getCanonicalAlias` |
| Own profile / avatars | `src/profile/useProfile.ts`, `src/Avatar.tsx` | `getUser`, `UserEvent.*`, `setDisplayName`, `setAvatarUrl`, `uploadContent`, `mxcUrlToHttp`, `getAccessToken` |
| Reactions / hand raise | `src/reactions/ReactionsReader.ts`, `useReactionsSender.tsx`, `src/reactions/index.ts` | `RoomEvent.Timeline/Redaction/LocalEchoUpdated`, `MatrixEventEvent.Decrypted`, `relations.getChildEventsForEvent`, `sendEvent`, `redactEvent`, membership `eventId`, `RelationType` |
| Call notifications | `CallViewModel/CallNotificationLifecycle.ts` | `DidSendCallNotification`, `RoomEvent.Timeline` + `EventType.RTCDecline` |
| Rageshake / dev settings | `src/settings/submit-rageshake.ts`, `rageshake.ts`, `FeedbackSettingsTab.tsx`, `DeveloperSettingsTab.tsx` | `getCrypto`, `sendEvent(org.matrix.rageshake_request)`, `ClientEvent.Event`, `secureRandomString`, `doesServerSupportUnstableFeature`, `getSFUConfigWithOpenID` |
| Analytics | `src/analytics/PosthogEvents.ts`, `PosthogAnalytics.ts` | `rtcSession.statistics`, account data |
| Types only | `src/UrlParams.ts`, `src/state/MediaDevices.ts`, `AndroidControlledAudioOutput.ts`, `IOSControlledAudioOutput.ts`, `initialMuteState.ts`, `state/media/RingingMediaViewModel.ts` (`RTCCallIntent`), `src/useEvents.ts` (`TypedEventEmitter` types) | replaced by a local `CallIntent` type / kept as generic emitter typing |
| Runtime misc | `src/useLocalStorage.ts` (`TypedEventEmitter`), `src/room/GroupCallErrorBoundary.tsx` (`MatrixError`), `src/room/KnockLobbyView.tsx` (shell) | see S6 |
| Context | `src/ClientContext.tsx` | `useClient`/`useClientState` used by `Avatar`, `sharedKeyManagement`, `useReactionsSender`, `submit-rageshake` |
Hosts: `component/index.tsx:291`, `src/room/useLoadGroupCall.ts:335`,
`sdk/main.ts:128` (own `MatrixRTCSessionManager`; waits on
`MatrixRTCSessionEvent.JoinStateChanged` at `:292`). Test kit:
`src/utils/test.ts` (`MockRTCSession`, `mockRtcMembership`, `mockMatrixRoom`),
`src/utils/test-viewmodel.ts`, `CallViewModelTestUtils.ts`. Baseline on
`main` (fe911628): tsc green, 97 unit files / 757 tests green.
Component build: `vite-component.config.ts` (single string `fileName`,
externals list with 18 `matrix-js-sdk/lib/*` subpaths), `pnpm lint:externals`,
`component/package.json` (`matrix-js-sdk: "*"` peer, `exports` without a
wildcard).
---
## 3. What the crate gives us, what it does not, and what must change in it
Verified against `src/uniffi_api/mod.rs`, `src/participation/mod.rs`,
`src/session/state.rs`, `src/own_membership/machine.rs`,
`src/encryption/matrix_encryption_event.rs` and the generated
`web-test-app/src/generated/matrix_rtc.ts` (acceptance suites: 32 pass).
**Provided** (`FfiParticipationManager`, one per `(room, slot)`, any number
share one `FfiMatrixDriver`):
- `join(FfiTransportIntent, FfiJoinParams)` / `leave(code?, reason?)`; a
`Publish` intent with a bare LiveKit transport triggers discovery through
`driver.getRtcTransports()` (`connections/mod.rs:470-499`); a driver _error_
there is `NoTransport`, not a fallback.
- `memberships()` + listener: `FfiMembership { member { memberId, userId,
deviceId, displayName?, avatarUrl?, intent?, applicationType?,
publishedTransports, canSubscribe }, state: Joined | LeftWithKeys,
connections: serviceUrl[] (the FFI doc comment saying ws urls is wrong),
transportIdentity?, mediaKey? }`. `transportIdentity` is today's
`rtcBackendIdentity`. `LeftWithKeys` entries have empty `connections`.
- `connections()` + listener: `{ connection { serviceUrl, wsUrl, jwtToken,
expiresAtTs }, members }[]`; tokens re-minted a minute before `exp`.
- `keyMap()` + `setKeyMapListener(map, change)`: `FfiMediaKey { memberId, key:
ArrayBuffer, index, creationTsMs: bigint }`, inbound keys **and our own**
(`encryption/inbound.rs:251-257`).
- `status()` + listener: `Disconnected{cause} | Joining | Connected{keepAlive,
membership, roster, encryption, impairments} | Leaving`.
- `session()`, `ownMemberId()` (available as soon as `join()` starts),
`ownMembership()`, `connectionProblems()`, `debugSnapshot()`.
- Member display names and avatars, from the room's `m.room.member` state
(C8): the session keeps them current, so a rename is a memberships change.
- Slots are required: once the seed has read slot state, a slot with no event
is closed, so a room without an `m.rtc.slot` has no call, in every dialect.
`openSlot(application, encrypted)` / `closeSlot()` send the state event;
the slot id `m.call#ROOM` matches js-sdk's default.
- `FfiElementCallCompat.{Off, StateEvents}`: spec MSC4143, or MSC3401 state
events for the clients that predate sticky events (C9 removed the never
deployed 2025 sticky dialect).
- All `u64` fields are `bigint` in TypeScript; `Vec<u8>` is `ArrayBuffer`.
- Listener callbacks arrive one timer tick after the emitting call (pumps
sleep through `setTimeout`, `executor.rs:86`); getters are fresh.
**Not provided — Element Call keeps it, through the driver** (§4.1 slices):
| Need | Why not in the crate | Where it goes |
| ------------------------------------------------------------------------------ | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Room name, alias, avatar, join rule, encryption flag | room metadata | `RoomDriver.getRoomInfo` |
| Profiles of room members who are not in the call (ringing, name-tag threshold) | not RTC | `RoomDriver.subscribeRoomMembers` |
| Reactions, hand raise, `org.matrix.rageshake_request` | application events | `TimelineDriver` |
| MSC4075 notification + decline | out of scope (only `wire_event_type` knows the type) | `TimelineDriver.sendRoomEvent`, decided in `CallNotificationLifecycle` (§4.3) |
| Own profile read/write | not RTC | `ProfileDriver` |
| `mxc://` thumbnails with auth | not RTC | `MediaDriver.thumbnailUrl` |
| Homeserver sync connectivity | needed by the crate too | `RtcMatrixDriver` (`isHomeserverConnected`, `subscribeConnectivity`, C12); reaches Element Call as `HomeserverUnreachable` in the status |
| Sticky-events support probe | capability probe | `MatrixDriver.getMatrixClientFeatures()` |
**Must change in the crate (S0a) — each blocks a later slice:**
| # | Problem | Change |
| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| C1 | A successful `read_state("m.rtc.slot")` returning `[]` marks slot state supplied and every slot other than the legacy `""` resolves `Closed` (`session/state.rs`); `join()` then fails with `SlotClosed` and every MSC4143 peer is excluded. Element Call never sent `m.rtc.slot`. | **Kept as the crate has it: no slot means no call.** Element Call opens the slot when nobody has (`RtcParticipationManager.join` with a `SlotPolicy`: `openSlot("m.call", encrypted)`, then wait for the echo), which needs the power level to send `org.matrix.msc4143.rtc.slot`; without it the join fails with `NoOpenSlotError`. Existing rooms keep working because the first call in a room opens its slot. A compat-mode relaxation was tried and reverted. |
| C2 | `manage_media_keys`, `require_cross_signed_sender`, `use_key_delay_ms` are not settable over the FFI; defaults are `true`, `true`, 1000 ms. | New record `FfiParticipationConfig { compat, manage_media_keys, require_cross_signed_sender, use_key_delay_ms }` as the constructor argument (replaces the bare `compat`). |
| C3 | `StickyEvents` compat sent keys as `org.matrix.msc4143.rtc.encryption_key` while deployed clients read only `io.element.call.encryption_keys`. | Made moot by C9 and reverted with it: the sticky dialect goes away entirely, so `Off` sends the spec key message and `StateEvents` the legacy one, with no middle case. |
| C4 | `FfiMember` has no membership `event_id`; reactions relate to it (§4.3). | `Member.event_id: Option<String>` threaded through `session/dispatch.rs` → `convert/*` → `state.rs` (currently dropped at `state.rs:402`) → `FfiMember.event_id`. |
| C5 | **Done.** Delegating the delayed leave (MSC4195) is split between the crate and the host: the draft's driver method makes the _adapter_ perform the whole delegation, its demo adapter calls a homeserver endpoint that 401s on a widget client, and Element Call today does it differently (probe, then the authorisation service's `get_token` with `delay_id`/`delay_timeout`/`delay_cs_api_url`). Nothing of this exists in the real `crates/` yet. | **The crate owns the policy; the driver keeps two primitives.** (a) `delegate_delayed_leave_via_homeserver(room, slot, member, delay_id)`: one authenticated POST to the CS API endpoint (`/_matrix/client/unstable/io.element.msc4195/rtc/livekit/delegate_delayed_leave`, the spelling Element Call probes today; a widget client answers `Unsupported`). (b) `LivekitTokenRequest.delegation: Option<{ delay_id, delay_timeout_ms }>`: the adapter appends `delay_id`, `delay_timeout` and `delay_cs_api_url` (its own homeserver URL) to the `get_token` / `sfu/get` body it already sends. The own-membership machine tries (a) first and, on `Unsupported` or any failure, (b) against the transport we publish on, i.e. Element Call's OpenID → JWT → scheduled-event path; only if both fail does it keep restarting the switch itself. **Arm-after-confirm:** the short delayed leave stays armed through the join; delegation arms a second, ≥ 1 h delayed leave, delegates _that_, and cancels the short one on success (or the long one on failure), so no moment is left without an armed leave and a failed delegation costs nothing. `KeepAlive::Delegated` says which route succeeded. The interim C5 (service URL and delay on the request) and Element Call's driver-side probe and JWT delegation are replaced by this. **Real-backend check (2026-09-15):** both routes send the MSC4195 `member` _claims_ (`{ id, claimed_user_id, claimed_device_id }`), not the member block of the event. The homeserver route body is what lk-jwt-service 0.7 accepts behind a Synapse that proxies `rtc/livekit/*` to it (MSC4512, `backend/app-service.yaml`): `{ url, room_id, slot_id, member, delay_id, delay_timeout }`, where `url` is the SFU websocket URL the transport's token named (the service checks it is its own). The transport resolver therefore returns `ResolvedTransport { transport, sfu_url }` and the machine carries the url into `Action::DelegateViaHomeserver`; without an SFU url it goes straight to the service route, and a receive-only member (no transport) delegates nothing. Verified end to end against `pnpm backend`: the homeserver route takes the 1 h leave (`KeepAlive::Delegated { via: Homeserver }`, one delayed event of 3 600 000 ms on the server). |
| C6 | The transport identity is a pure function (`connections/mod.rs:116-135`) but not exported; the own identity is needed before our membership echo to set our own media key. | Export `FfiParticipationManager.own_transport_identity(): Option<String>`. |
| C7 | Doc comment on `FfiMembership.connections` says `ws_url`s. | Fix the comment. |
| C8 | Both converters set `display_name` / `avatar_url` to `None`, so every host would re-derive them from room members. | The session records each `m.room.member` profile (whether or not the roster condition is enforced) and stamps it on members at projection time. |
| C9 | `ElementCallCompat::StickyEvents` models the 2025 Element Call sticky dialect (`member: {user_id, device_id, id}`, flat `rtc_transports`, `versions`, legacy key message). No deployment uses it: sticky-event calls have not shipped. | **Done.** Removed: `own_membership/compat_2025.rs`, the 2025 block in `session/convert/msc4143.rs` and its dispatch arm, the `StickyEvents` variants of `ElementCallCompat` / `FfiElementCallCompat`, and their tests and acceptance tests; `outbound_event_type` / `build_content` keep two arms (`Off`, `StateEvents`). Element Call then maps `Matrix_2_0 → Off`: spec MSC4143 sticky events with slots (which Element Call opens, C1) and the spec key message. |
| C10 | `MediaKeyState` has `holds_our_key`, `have_their_key` and `rejection`, so a tile learns about an unsigned sender only when `require_cross_signed_sender` is on (the key is discarded, `rejection: NotCrossSigned`). With the check off the verdict is dropped on accept and the tile cannot show an unverified sender. | **Done.** The inbound key store keeps the MSC4153 verdict of the accepted key per member and exposes it as `FfiMediaKeyState.sender_cross_signed: Option<bool>` (`None` when the host could not tell). Element Call runs with the check off (§5.8) and wants to show the state on the tile until it is turned on. |
| C11 | No way to change `application["m.call.intent"]` while joined; Element Call flips it between `audio` and `video` when the camera is toggled (`updateCallIntent`). | `update_application(intent)` on the own-membership manager, facade and FFI: while connected the membership is re-published at once on the refresh path (a failure retries like a refresh); during a join the join event carries it; refused with `NotJoined` otherwise. |
| C12 | Homeserver connectivity lived only in Element Call's driver; the crate could not tell a dead homeserver from a quiet one, and a participation's status said nothing about it. | **Done.** `ConnectivityDriver` (`is_homeserver_connected`, `subscribe_connectivity`) joins the `MatrixDriver` sum; the FFI adds `ConnectivitySink`, the two callback methods and `FfiParticipationManager.is_homeserver_connected()`; the facade pump consumes the stream and reports `Impairment::HomeserverUnreachable { since_ts }` (Critical, sorted first) in every non-disconnected status until the driver reports the homeserver back. The web-test-app mock and js-sdk driver implement it. A matrix-rust-sdk adapter implements the same two methods later. |
| C13 | `session()` moved (seed done, slot opened by somebody else) without any listener firing, so a host's `session$` stayed stale until a membership or status change happened to refresh it. Found by the real-backend check. | **Done.** `SessionListener` / `set_session_listener` on the FFI manager (`on_session_change` on the facade), fired publish-on-change from `refresh_outputs`; `RtcParticipationManager.session$` is fed from it. |
| C14 | The crate logs through the `log` facade (104 call sites) but installed no logger, so in wasm every line was dropped: seeding, joins, delegation fallbacks and key rotation left no trace in a rageshake. | **Done (2026-09-16).** `LogSink` foreign trait (`log(level, target, message)`), `FfiLogLevel`, and `set_log_sink(sink, max_level)`, which installs a `log::Log` forwarding to the sink (a host that already installed a Rust logger keeps it). Element Call installs `matrixRtcLogSink` under `[matrix-rtc]` on the js-sdk root logger from `initMatrixRtcSdk()` at debug; the web-test-app installs a console sink (warn in tests). |
No crate work is deferred: `update_application` is C11.
---
## 4. Target architecture
```text
host (SPA · widget · sdk · a third-party page)
├─ RtcMatrixDriver = the crate's MatrixDriverCallback, verbatim
│ (events, to-device, tokens, sinks, connectivity)
└─ ElementCallMatrixClientDriver = RoomDriver + TimelineDriver + ProfileDriver
+ MediaDriver + capabilities
│
▼ component/index.tsx <ElementCall rtcDriver clientDriver …/>
┌─ MatrixDriverProvider (src/driver/MatrixDriverContext.tsx) ─────────────┐
│ CallView owns one RtcParticipationManager for lobby → call → ended │
│ RtcParticipationManager (src/state/rtc/RtcParticipationManager.ts) │
│ FfiMatrixDriver(driver) → FfiParticipationManager(room, slot, me, cfg)│
│ memberships$ · connections$ · keyChanges$ · status$ · session$ │
│ ownMemberId$ · ownMembership$ · ownTransportIdentity$ │
│ join(intent, params) · leave(reason) │
│ │ │
│ ▼ │
│ createCallViewModel$(scope, callParticipation, roomInfo, mediaDevices, …) │
│ ConnectionManager ← connections$ (wsUrl + jwt; no OpenID in EC) │
│ RemoteMembers ← memberships$ (transportIdentity ↔ LK participant)│
│ MatrixKeyProvider ← keyChanges$ (memberId → transportIdentity) │
│ LocalMember ← status$, ownMembership; join/leave → participation│
│ MemberMetadata ← driver.room members │
│ Notifications ← driver.timeline + memberships$ │
│ React: Lobby / InCall / Settings / Avatar / Reactions │
│ read the driver via useMatrixDriver(), never a client │
└──────────────────────────────────────────────────────────────────────────┘
```
### 4.1 The two drivers (host-facing, framework-neutral)
Two files, two objects. `src/driver/RtcMatrixDriver.ts` is one line: the
crate's `MatrixDriverCallback`, re-exported. Everything MatrixRTC, including
homeserver connectivity (C12), goes through it and is consumed by the crate.
`src/driver/ElementCallMatrixClientDriver.ts` is what a call needs beyond
MatrixRTC. Plain TypeScript: async methods and
`subscribeX(listener) → unsubscribe` pairs, mirroring the crate's sink style.
No RxJS crosses this boundary; Element Call wraps subscriptions into
`Behavior`s internally (`src/driver/observe.ts`).
```ts
export interface ElementCallMatrixClientDriver
extends RoomDriver, TimelineDriver, ProfileDriver, MediaDriver {
readonly userId: string;
readonly deviceId: string;
/** The room this driver is bound to (one driver per room, as in the crate). */
readonly roomId: string;
getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form diagnostics for rageshakes (crypto version, sync state, …). */
getDiagnostics?(): Promise<Record<string, string>>;
}
export interface MatrixClientFeatures {
stickyEvents: boolean;
/** The host's events carry decryption metadata (false on a widget client). */
verifiedEventOrigins: boolean;
/** The host can evaluate MSC4153 cross-signing of senders. */
crossSigningVerdicts: boolean;
}
// src/driver/RtcMatrixDriver.ts
export type RtcMatrixDriver = MatrixDriverCallback;
export interface RoomInfo {
name: string;
canonicalAlias: string | null;
avatarUrl: string | null;
joinRule: string | null;
encrypted: boolean;
}
export interface RoomMemberProfile {
userId: string;
displayName: string | null;
avatarUrl: string | null;
membership: "join" | "invite";
}
export interface RoomDriver {
getRoomInfo(): RoomInfo;
subscribeRoomInfo(listener: (info: RoomInfo) => void): () => void;
getRoomMembers(): RoomMemberProfile[];
subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): () => void;
}
export interface TimelineEvent {
eventId: string;
type: string;
sender: string;
content: Record<string, unknown>;
originServerTs: number;
redacts?: string;
}
export interface TimelineDriver {
sendRoomEvent(
eventType: string,
content: unknown,
): Promise<{ eventId: string }>;
redactEvent(eventId: string): Promise<void>;
/** Decrypted live room events (not sticky), incl. redactions; no local echoes. */
subscribeTimeline(listener: (event: TimelineEvent) => void): () => void;
/** Events already known that relate to `eventId` (hand-raise catch-up). */
getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[];
}
export interface OwnProfile {
displayName: string | null;
avatarUrl: string | null;
}
export interface ProfileDriver {
getOwnProfile(): OwnProfile;
subscribeOwnProfile(listener: (profile: OwnProfile) => void): () => void;
/** Absent when the host does not allow profile changes. */
setDisplayName?(name: string): Promise<void>;
setAvatar?(file: Blob): Promise<void>;
}
export interface MediaDriver {
/** An `<img>`-usable URL for an mxc thumbnail (may be a blob: URL), or null. */
thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null>;
}
```
The client driver is a union of capability slices, the way the crate splits
its own driver; the RTC driver is the crate's contract untouched, so a host
with a crate-side adapter (matrix-rust-sdk) implements nothing extra for
MatrixRTC.
### 4.2 `RtcParticipationManager` (Element Call's RxJS view of the manager)
Naming: _participation_ is the crate's FFI concept (`FfiParticipationManager`,
`FfiParticipationConfig`); `RtcParticipationManager` is Element Call's RxJS wrapper
over it.
`src/state/rtc/RtcParticipationManager.ts`, a class taking the scope in its constructor:
```ts
new RtcParticipationManager(scope, driver, {
slotId: "m.call#ROOM", compat, manageMediaKeys, requireCrossSignedSender,
useKeyDelayMs, transportFallbackUrl?, logger })
memberships$: Behavior<Epoch<FfiMembership[]>> // Joined only; LeftWithKeys filtered (v1)
connections$: Behavior<FfiConnectionWithMembers[]>
keyChanges$: Observable<FfiMediaKey> // one changed key per emission
keyMap$: Behavior<FfiMediaKey[]>
status$: Behavior<FfiStatus>
session$: Behavior<FfiSessionSnapshot>
ownMemberId$: Behavior<string | null>
ownTransportIdentity$: Behavior<string | null>
ownMembership$: Behavior<FfiMembership | null>
join(intent: FfiTransportIntent, params: FfiJoinParams): Promise<void>
leave(code?: string, reason?: string): Promise<void>
```
- Wraps `new FfiMatrixDriver(driver)` and `new FfiParticipationManager(...)`;
`uniffiDestroy()` on scope end (leave first unless `Disconnected`).
- `transportFallbackUrl` decorates `getRtcTransports`: when the host's call
**throws or returns no LiveKit transport**, answer with
`Config.get().livekit.livekit_service_url` (today's precedence,
`RtcTransportAutoDiscovery.ts:72-94`).
- Lives at `CallView` level (the lobby reads memberships for the participant
count, auto-mute threshold and notification decision, `CallView.tsx:164-413`;
`useReactionsSender` needs the own membership). `join()` after `leave()` on
one manager is supported and mints a fresh member id.
- Behaviors are seeded from the getters and updated by the listeners.
### 4.3 `createCallViewModel$` after the change
```ts
createCallViewModel$(
scope,
participation,
roomInfo,
mediaDevices,
muteStates,
options,
handsRaised$,
reactions$,
trackProcessorState$,
);
// roomInfo: { roomId, userId, deviceId, members$: Behavior<RoomMemberProfile[]>,
// homeserverConnected$: Behavior<boolean>, timeline: TimelineDriver }
```
| Today | After |
| ------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createMemberships$(scope, rtcSession)` | `callParticipation.memberships$` |
| `membershipsAndTransports$` (`getTransport(oldest)`) | `membership.connections[]` (service urls) |
| `createLocalTransport$` + `RtcTransportAutoDiscovery` + `openIDSFU` | deleted; `join(Publish(custom url or bare))`; own transport = `ownMembership.member.publishedTransports[0]` |
| `Connection.start()` fetching a JWT | `Connection` takes `{ wsUrl, jwt, expiresAtTs }` from `connections$`, keyed by `serviceUrl`, holds `token$`; a refreshed token is used on the next full (re)connect; `expiresAtTs` logged on connect; `livekitAlias` decoded from the JWT stays |
| `createRemoteMatrixLivekitMembers$` on `rtcBackendIdentity` | matches `membership.transportIdentity`; key = `member.memberId` |
| `MatrixKeyProvider.setRTCSession` | `MatrixKeyProvider.attach(participation)`: `keyChanges$` × `memberships$` × `ownTransportIdentity$` → `onSetEncryptionKey(material, identity, index)`; keys whose member has no identity yet are held per member id and replayed |
| `enterRTCSession` (`joinRTCSession(...)`) | `callParticipation.join(intent, joinParamsFromConfig(...), { encrypted: roomInfo.encrypted, canOpen: roomInfo.canOpenSlot })` in the same `scope.reconcile`: opens the room's slot first when none is open (power level permitting, otherwise `NoOpenSlotError`), then joins; cleanup calls `callParticipation.leave()` |
| `createHomeserverConnected$` | `status$` alone: `Impairment::HomeserverUnreachable` (C12) = disconnected; `Connected` with `keepAlive` `Armed`/`Delegated`/`Unavailable` = connected; `RestartFailing`/`Expired` = reconnecting (**behaviour change**: local media pauses in that window, today it does not). Outside a participation, `RtcParticipationManager.homeserverConnected$` from the manager's getter |
| `delayId$` + JWT-service delegation | gone from Element Call. `FfiJoinParams.delegateDelayedLeave` is always `true`; the crate tries the CS API, then the authorisation service's token endpoint (with `delay_id`, `delay_timeout`, `delay_cs_api_url`), then falls back to its own restarts (C5). `config.matrix_rtc_session.delegated_delayed_leave.delay_ms` becomes `FfiJoinParams.delegatedDelayMs` (default 1 h) |
| `createMatrixMemberMetadata$(scope, matrixRoom)` | tiles read `member.displayName` / `avatarUrl` from the crate; disambiguation runs over the call's members; `roomInfo.members$` remains only for the ringing name and the name-tag threshold |
| `createSentCallNotification$` / `createReceivedDecline$` | `CallNotificationLifecycle`: after **our own membership echo** (`ownMembership$` non-null) and when no other member was in the session before our join, send `m.rtc.notification` (wire `org.matrix.msc4075.rtc.notification`) via `driver.sendRoomEvent` with the fields js-sdk sends today (`m.mentions`, `notification_type`, `sender_ts`, `lifetime` 90 s, `m.call.intent`, `m.relates_to: m.reference → own membership event id`, `MatrixRTCSession.ts:725-756`); decline from `driver.subscribeTimeline` on both `org.matrix.msc4310.rtc.decline` and `m.rtc.decline` |
| `updateCallIntent` on camera toggle | `callParticipation.updateApplication(videoEnabled ? "video" : "audio")` (C11); Element X's room-header intent keeps following the camera |
| `createKeyRotationSuppressed$` + `key_rotation_participant_limit` | dropped (the crate has no participant limit; the indicator has no equivalent) |
| `rtcSession.statistics` (PostHog) | counts of `keyChanges$` (sent = own member id, received = others) |
| `MembershipManagerError` → `StickyEventsRequiredError` | `status$` `Disconnected{cause: JoinFailed{error: Driver/Unsupported}}` → `StickyEventsRequiredError`; `NoTransport` → `MatrixRTCTransportMissingError`; `ManagerStopped`/`SlotClosed` → `ConnectionLostError` |
| `impairments` | fed into the inert `src/state/ServiceInterruptionsViewModel.ts` (S6) |
Join parameters (`joinParamsFromConfig`, from `Config.get().matrix_rtc_session`):
`stickyDurationMs = BigInt(Math.min(membership_event_expiry_ms ?? 4 h, 1 h))`
(js-sdk caps sticky at 1 h, the crate clamps to 1 h; the config default is
undefined today); `keepAliveTimeoutMs = BigInt(delayed_leave.delay_ms)`;
`degradedLifetimeMs = undefined`; `applicationType = "m.call"`;
`intent = options.callIntent`. Config keys that stop having an effect and are
documented as such in `docs/`: `delayed_leave.restart_ms`,
`restart_timeout_ms`, `network_error_retry_ms`,
`key_rotation_participant_limit`, `delegated_delayed_leave.*` (the crate arms
1 h when delegating). `wait_for_key_rotation_ms` maps to
`FfiParticipationConfig.useKeyDelayMs`.
Compat: `MatrixRTCMode.Compatibility → StateEvents`, `Matrix_2_0 → Off` (spec
MSC4143: sticky member events, slots, the spec key message), in
`compatForMode`.
### 4.4 React tree
- `src/driver/MatrixDriverContext.tsx`: `MatrixDriverProvider` holding both
drivers, `useRtcMatrixDriver()` / `useClientDriver()`; replaces every
`useClient()`/`useClientState()` under `CallView`. `ClientContext` stays for the shell.
- `CallView` props: `{ driver, isPasswordlessUser, confineToRoom, preload, skipLobby }`.
It creates the `RtcParticipationManager` (scope tied to its mount) and hands it to
`LobbyView`, `ActiveCall`, `useReactionsSender`. `MatrixInfo` comes from
`driver.getRoomInfo()` / `driver.getOwnProfile()`.
- `InCallView`'s own id becomes `${driver.userId}:${driver.deviceId}`.
- `Avatar` → `driver.thumbnailUrl` (host bridge `downloadMedia` first).
- `useProfile(client)` → `useOwnProfile()`; `ProfileSettingsTab` hides editing
when `setDisplayName` is absent.
- `ReactionsReader(scope, participation, driver)`, `useReactionsSender` over
`TimelineDriver`. Relation target stays the **current own membership event
id** (`member.eventId`, C4) — protocol status quo; the reader keys raised
hands by `memberId` and re-resolves the event id on re-send instead of
dropping the hand.
- `useRoomEncryptionSystem` reads `getRoomInfo().encrypted`.
- `submit-rageshake`: `useMatrixDriver()` for ids and `getDiagnostics?()`;
rageshake requests via `subscribeTimeline`.
- `DeveloperSettingsTab`: sticky probe → `getMatrixClientFeatures()`; custom LiveKit
URL validation → `driver.getLivekitToken(...)`.
- `window.rtcSession` debug handle → `window.matrixRtc = { participation }`.
### 4.5 Hosts
- **Component**: `ElementCallProps.rtcDriver: RtcMatrixDriver` and
`clientDriver: ElementCallMatrixClientDriver`; `roomId` stays as an
**optional** prop for one release and must equal `clientDriver.roomId` when given
(assertion), then goes. `initializeElementCall(config, { matrixRtcWasm? })`
awaits `initMatrixRtcSdk()`. Externals shrink to `react*`, `livekit-client`,
`matrix-js-sdk/lib/logger`. `JsSdkRtcMatrixDriver` and `JsSdkElementCallMatrixClientDriver` are
exported from a second entry `@element-hq/element-call-component/matrix-js-sdk` (needs
`lib.fileName` as a function and a new `exports` key); only that entry has
`matrix-js-sdk` as a peer.
- **SPA / widget**: `useLoadGroupCall` returns the `Room`; `RoomPage` memoises
the two js-sdk drivers and renders `<CallView rtcDriver clientDriver>`. The
widget capability list in `src/widget.ts` grows by: send and receive state
`m.rtc.slot` and `org.matrix.msc4143.rtc.slot` (opening the slot), `m.room.avatar`,
`m.room.canonical_alias`, `m.room.join_rules`; send/receive to-device
`org.matrix.msc4143.rtc.encryption_key` and `m.rtc.encryption_key`
(alongside `io.element.call.encryption_keys`); events `m.rtc.decline`.
- **`sdk/main.ts`**: builds the driver from the widget client, a
`RtcParticipationManager`, and waits on `status$` instead of `JoinStateChanged`.
- **`component/dev` harness**: the two js-sdk drivers per pane.
### 4.6 The js-sdk drivers — two classes, two clients each
`src/driver/jsSdk/JsSdkRtcMatrixDriver.ts` (the crate seam, a port of the
draft's `jsSdkDriver.ts`, connectivity from `ClientEvent.Sync`) and
`JsSdkElementCallMatrixClientDriver.ts` (the §4.1 slices), both written so
that they work on a full `MatrixClient`
**and** on js-sdk's `RoomWidgetClient` (`node_modules/matrix-js-sdk/src/embedded.ts`).
Differences from the draft, all required by the widget client:
- to-device inbound: listen on `ClientEvent.ToDeviceEvent` (the widget client
never emits `ReceivedToDeviceMessage`, `embedded.ts:788-802`); on a full
client use `ReceivedToDeviceMessage` for the `encryptionInfo`.
- to-device outbound: always `encryptAndSendToDevice` (works without a crypto
backend on the widget client, `embedded.ts:598-612`; plain `sendToDevice`
there is **unencrypted**, `:614-619`).
- transports: `client._unstable_getRTCTransports()` (the widget override,
`embedded.ts:641-646`), never a raw `http.authedRequest`; `.well-known`
fallback only on a full client.
- event origins: on a full client from decryption metadata (as the draft); on
a widget client events arrive decrypted without metadata, so the driver
reports `Encrypted{ senderDeviceId: content.member.device_id }` for member
events and `Encrypted{ senderDeviceId: content.device_id }` for key
events, i.e. the _claimed_ trust level js-sdk applies today
(`ToDeviceKeyTransport.ts:133-140`). `getMatrixClientFeatures().verifiedEventOrigins`
says which.
- cross-signing verdict: `undefined` on a widget client
(`crossSigningVerdicts: false`); Element Call then forces
`requireCrossSignedSender = false`.
- delegation: two primitives and no policy. `delegateDelayedLeaveViaHomeserver`
is one `authedRequest` on a full client and `Unsupported` on a widget
client; `getLivekitToken` appends `delay_id`, `delay_timeout` and
`delay_cs_api_url` (`client.baseUrl`) when the request carries a
delegation. The crate decides when to call which (C5). Element Call carries no
delegation probe or policy.
- sticky listener attached after `startClient()` resolves (the widget room
only exists then, `embedded.ts:326`).
- `getLivekitToken` reuses today's request shapes (`slot_id: "m.call#ROOM"`,
legacy `/sfu/get`), errors mapped to `RtcError` incl. `M_LIMIT_EXCEEDED →
RateLimited`, 403 → `Rejected`, 404/`M_UNRECOGNIZED` → `Unsupported`.
---
## 5. Decisions and assumptions
1. **The SDK is an npm package.** `@element-hq/matrix-rtc` (the draft's
`web-test-app/`, built by `npm-web-bindings.yml`: uniffi bindings, the
wasm-bindgen glue, a `wasm-opt`'d ~760 KB wasm, `.d.ts`, and `@ubjs/core`
as its own dependency) is a devDependency, aliased in `package.json` to
`@billcarsonfr/matrix-rtc@next` while it is published from the fork, so
the imports already say `@element-hq/matrix-rtc`. It lives on the GitHub
Packages registry: `.npmrc` maps the scope, a `read:packages` token goes
in `~/.npmrc` (CI: the `MATRIX_RTC_NPM_TOKEN` secret). **The GitHub
Packages registry is a stop-gap** for the phase in which the crate's API
moves with every push; once the SDK is ready for consumers beyond Element
Call it belongs on npmjs.com as `@element-hq/matrix-rtc`, which drops the
alias, `.npmrc`, the CI secret and every token requirement
(`docs/matrix_rtc_sdk.md`). `src/matrix-rtc-sdk/`
keeps only the loader (`initMatrixRtcSdk`: package `initAsync` + our log
sink, wasm via `@element-hq/matrix-rtc/wasm?url`) and the curated
re-exports. _History:_ until 2026-09-16 the bindings were vendored under
`src/matrix-rtc-sdk/generated/` by `scripts/sync-matrix-rtc-sdk.sh`; local
crate work now goes through `pnpm links:on` (`docs/linking.md`).
2. **Wasm loading.** Verified: Vite 8 library mode inlines `?url` and
`new URL(…, import.meta.url)` assets as base64 regardless of
`assetsInlineLimit`; `?url&no-inline` emits a file. App builds use `?url`;
the component build uses `?url&no-inline` plus an `exports` entry for
`./dist/assets/*`, and `initializeElementCall(config, { matrixRtcWasm })`
lets a host point elsewhere. Wasm boot is **lazy** everywhere: in the app
`useRtcParticipationManager` awaits `initMatrixRtcSdk()` before constructing
the participation (so the js-sdk path never fetches it, §5.15), not the
`Initializer`; vitest reads the file from disk and only suites that need
it call `initMatrixRtcSdk()`, never in
`src/vitest.setup.ts`; Storybook boots it in `.storybook/preview.tsx`
`beforeAll`. Suites using `vi.useFakeTimers` never share a file with
real-wasm tests (pumps sleep on `setTimeout`).
3. **Own identity via the driver.** `userId`/`deviceId` are properties of the
driver, not props.
4. **One driver per room**, as in the crate.
5. **`matrix-js-sdk/lib/logger` stays** behind `src/utils/logger.ts`.
6. **`updateCallIntent` stays**, through the crate's `update_application` (C11).
7. **Reactions relate to the current own membership event id** (status quo);
reader keys by `memberId`. Alternatives (stable join event id, or
`memberId` as relation target) are protocol changes left to the user.
8. **MSC4153 default off** (`requireCrossSignedSender = false`) for every
host, confirmed: js-sdk performs no such check today, and a passwordless
SPA peer would otherwise be inaudible to everyone. The intent is to turn
it on; the default carries a TODO (`src/state/rtc/joinParams.ts`), and C10
puts the sender's verdict on the tile so the UI can show it meanwhile.
9. **Delegation is the crate's alone.** Element Call always asks for it; the
crate tries the CS API, then the authorisation service's token endpoint,
then its own restarts, and arms the long delegated leave only once
delegation is confirmed (C5, done). Element Call keeps no probe and no
delegation code; `delegated_delayed_leave.delay_ms` feeds
`FfiJoinParams.delegatedDelayMs`.
10. **Widget trust model:** origins synthesised from claimed device ids equal
today's js-sdk trust level; the crate records them as
`DeviceAttribution::Verified` because it cannot tell. Documented in the
driver; a `Claimed` attribution flag on the sink is a follow-up crate ask.
11. **Scratch files** go to `agent-workspace/oxidation/`; this plan lives at
the repo root because it was asked for by name.
12. **Branch** `toger5/oxidation`, one commit per slice.
13. **Compatibility mode has no slot.** Under `StateEvents` the crate projects
the session from the MSC3401 state events alone and requires the legacy
slot id `""` (`LEGACY_SLOT_ID` in `src/state/rtc/slot.ts`,
`slotIdForCompat`); `RtcParticipationManager` picks it from the config and skips
the slot check and the slot open. Found by the real-backend check: with
`m.call#ROOM` the crate saw its own legacy membership as a candidate but
never projected it. Element Call's own rooms already let every member send
the legacy member state event (`state_default: 0`); a plain room does not,
which the backend test reproduces.
14. **Member events in encrypted rooms are Megolm-encrypted** by matrix-js-sdk
(sticky events are timeline events; the SDK exempts only reactions and
redactions), as they are with the js-sdk MatrixRTC code today. The driver
decrypts them on the way in; the sticky marker (`msc4354_sticky`) stays in
the clear. To cross-check against Element X before relying on it.
15. **The two view models stay switchable for one release.** A developer
setting, `callViewModelImplementation` (`"matrix-js-sdk"` |
`"matrix-rtc"`, `src/settings/settings.ts`, next to `matrixRTCMode`),
picks between `createJsClientCallViewModel$` and the driver-based
`createCallViewModel$` in the same build. Like `matrixRTCMode` it is
shown in the Developer Settings tab, sampled when the call is joined
(switching mid-call needs a rejoin), and a deployment can pin it through
`config.json` (`call_view_model_implementation`), which overrides the
user's choice and greys the control out. The default is
`"matrix-js-sdk"` when the setting lands (S4a) and flips to
`"matrix-rtc"` once the S5 gate is green; S6 removes the setting together
with the js-sdk path. The point is that a broken call can be compared
against the old path in the same session, and that Playwright can run
both paths from one build. Everything the js-sdk path needs (the client,
the `MatrixRTCSession`, `ReactionsReader` over the session) therefore
stays reachable from `CallView` until S6, and the `RtcParticipationManager` is
created only when the crate path is selected, so neither path pays for
the other.
---
## 6. Work breakdown
Each slice is independently green (`pnpm lint && pnpm test:unit` at least).
### S0a — crate changes ☑ (in `MatrixSdkArchitectureDraft`)
C2–C12 from §3 (C1 reverted), each with a Rust unit test; acceptance tests in
`web-test-app/test/`: a slot-less room refuses joins until `openSlot`, in every dialect;
`FfiParticipationConfig` with `manageMediaKeys: false` exchanges no keys;
no `StickyEvents` variant left anywhere (C9); `member.eventId` present;
delegation tries the homeserver first, then the token endpoint with the delay fields, and keeps the short leave armed until one succeeds;
`ownTransportIdentity()` equals the joined membership's `transportIdentity`.
Gate: `cargo test --features uniffi`, `cargo clippy --all-targets --features uniffi -- -D warnings`,
`npm run ubrn:web && npm test`.
### S0b — SDK intake ☑
- ~~`scripts/sync-matrix-rtc-sdk.sh`, `src/matrix-rtc-sdk/generated/**`~~
(replaced by the `@element-hq/matrix-rtc` package on 2026-09-16, §5.1),
`src/matrix-rtc-sdk/index.ts` (loader + curated re-exports),
`src/matrix-rtc-sdk/index.test.ts` (boot, one join round-trip against the
TS mock driver).
- `package.json` (~~`@ubjs/core`~~ now `@element-hq/matrix-rtc`; the
`generated/**` carve-outs in `knip.ts`, `.oxlintrc.json`, `.oxfmtrc.json`,
`vitest.config.ts` and `.gitattributes` went with the vendored files),
`vite.config.ts` (nothing needed for `?url`; verified `vite-plugin-wasm`
ignores it), `tsconfig.json` untouched thanks to the `.d.ts`.
- Gate: `pnpm lint && pnpm format:check && pnpm test:unit`.
### S1a — driver interface, mock driver ☑
- `src/driver/RtcMatrixDriver.ts`, `src/driver/ElementCallMatrixClientDriver.ts`,
`src/driver/observe.ts`, `MockRtcMatrixDriver.ts` (port of
`web-test-app/src/mockDriver.ts`) and `MockElementCallMatrixClientDriver.ts`
(in-memory room info, members, timeline, profile), with a test each.
- `knip.ts` `ignore` for `src/driver/**` until consumed, with the reason.
### S1b — `JsSdkMatrixDriver` ☑
- `src/driver/jsSdk/JsSdkRtcMatrixDriver.ts` and
`JsSdkElementCallMatrixClientDriver.ts` (§4.6) + tests against **two**
fakes: a `MatrixClient`-shaped one (`mockMatrixRoom`) and a
`RoomWidgetClient`-shaped one (`ToDeviceEvent`, no crypto,
`_unstable_getRTCTransports`, sticky updates after `startClient`).
Asserts request shapes of `_unstable_sendStickyEvent`,
`_unstable_sendStickyDelayedEvent`, `_unstable_updateDelayedEvent`,
`encryptAndSendToDevice`, `/get_token` body with and without the delegation fields, `delegateDelayedLeaveViaHomeserver` on both clients,
sink emission and origin synthesis, room-info/member updates.
### S2 — `RtcParticipationManager` ☑
- `src/state/rtc/RtcParticipationManager.ts`, `joinParams.ts`, `transportIntent.ts`,
`errors.ts` (cause → `ElementCallError`).
- `RtcParticipationManager.test.ts` through the real wasm + `MockMatrixDriver`:
memberships follow a remote join/leave; `LeftWithKeys` filtered; join →
`Connected`; `connections$` carries the token; `keyChanges$` fires for a
peer key; `ownTransportIdentity$` set before the echo; leave →
`Disconnected{LeftByHost}`; join → leave → join; scope end destroys the
manager; fallback transport when the host throws or advertises none.
### S3 — view model, four slices ☑ (side by side with the js-sdk view model)
- **Shape (2026-09-15):** the existing factory is renamed
`createJsClientCallViewModel$` and the new `createCallViewModel$(scope,
participation, clientDriver, …)` sits next to it; both build a
`CallViewModelCore` and hand it to the shared `assembleCallViewModel`, so
the layout/tile half is one piece of code and the two Matrix sides can be
reviewed side by side. `MatrixLivekitMember.membership$` is a neutral
`CallMember` (`userId`, `deviceId`, `memberId`, `rtcBackendIdentity`) that
js-sdk's `CallMembership` satisfies; `CallNotificationLifecycle` takes a
neutral `DeclineEvent`. Nothing js-sdk is deleted yet (that is S6).
- **S3a** `remoteMembers/ParticipationConnections.ts`: one `Connection` per
service URL in `participation.connections$`, started with the crate's
token; a refreshed token keeps the connection (used on the next
(re)connect). `Connection`/`ECConnectionFactory` accept a `null` client.
Test fakes live in `src/utils/test-participation.ts`
(`FakeParticipation`, `fakeMembership`, `fakeConnection`, `fakeMediaKey`).
- **S3b** `remoteMembers/ParticipationMembers.ts` (remote members matched by
`transportIdentity`, keyed by `memberId`; `callMemberOf`) and
`e2ee/participationKeyProvider.ts` (`keyMap$` × `memberships$` ×
`ownTransportIdentity$` → `onSetEncryptionKey`, once per (member, index,
identity); a key that arrives before the identity waits for the roster).
- **S3c** `localMember/LocalMedia.ts` is the LiveKit half extracted from
`LocalMember.ts` (publisher, tracks, screen share, upstream pausing, host
notify) and shared; `localMember/ParticipationLocalMember.ts` joins and
leaves through the participation (slot policy from `roomInfo`, custom
LiveKit URL as a `Publish` intent, `updateApplication` on camera toggle),
derives connected / reconnecting / `probablyLeft` from `status$`
(`HomeserverUnreachable` → "sync", `RestartFailing`/`Expired` →
"probablyLeft") and the fatal error from `errorForStatus`. Deletions
(`LocalTransport.ts`, `RtcTransportAutoDiscovery.ts`,
`HomeserverConnected.ts`, `openIDSFU.ts`, `enterRTCSession`) wait for S6.
- **S3d** `ParticipationCallNotification.ts` sends `org.matrix.msc4075.rtc.notification`
after our own echo when nobody was in the session before us (resets on
leave) and reads declines from the `TimelineDriver`;
`remoteMembers/ParticipationMemberMetadata.ts` adapts the client driver's
roster to `RoomMemberMap` so `createMatrixMemberMetadata$` and the ringing
name work unchanged. Still open: `ReactionsReader` on `participation` +
`TimelineDriver` (the new factory takes the hands/reactions observables
as inputs like the old one; S4b), `keyRotationSuppressed$` is `constant(false)`,
and the js-sdk test kit stays until S6. Tests:
`CallViewModel.participation.test.ts` (real wasm, mock drivers, mocked
LiveKit: join → connection → own tile → peer → leave; transport-missing →
`fatalError$`), plus one file per module.
### S4 — React tree, two slices ☑ (2026-09-16)
- **S4a-0, the switch (§5.15) ☑:** `CallViewModelImplementation` enum,
`callViewModelImplementation` setting (`src/settings/settings.ts`),
`call_view_model_implementation` pin (`ConfigOptions.ts`, validated in
`Config.ts`), the radio group in `DeveloperSettingsTab.tsx`, the
implementation logged at join and sent as `call_view_model_implementation`
in rageshakes, `effectiveCallViewModelImplementation()` in
`src/state/rtc/implementation.ts`. `CallView` samples it once per mount;
with `matrix-rtc` and no drivers it warns and lets matrix-js-sdk carry the
call. Still open from this item: the Playwright helper and the second CI
run.
- **S4a done so far:** `MatrixDriverProvider` / `useMatrixDrivers()`
(`src/driver/MatrixDriverContext.tsx`), provided by the component from its
props and by `RoomPage` through `useJsSdkDrivers(client, room)`;
`useRtcParticipationManager(drivers, config)` owned by `CallView`, created only
when the crate path is selected; `ActiveCall` takes `participation` and
builds either view model; the lobby's member count, the big-call auto-mute
and the error boundary's "were we joined" read from whichever side carries
the call; `window.matrixRtc = { participation }` next to `window.rtcSession`.
- (The original S4a-0 description follows for reference.) `callViewModelImplementation` setting and
`config.json` pin (`ConfigOptions.ts`, validated at load like
`matrix_rtc_mode`); a radio group in `DeveloperSettingsTab.tsx` beside the
MatrixRTC mode; `ActiveCall` reads the sampled value and calls either
factory — for `"matrix-rtc"` it takes the drivers from the
`MatrixDriverProvider` and the `RtcParticipationManager` from `CallView`, for
`"matrix-js-sdk"` it keeps `rtcSession`/`matrixRoom` as today. The chosen
implementation is logged at join and added to the rageshake fields so a
report says which path it came from. Playwright gets a helper that sets
the setting (local storage) before the call so every call spec can run
under both values; CI runs the suite twice until the default flips.
Lands first in S4a, before anything else in the tree moves, so that every
later S4 change is verifiable against the old path.
- **S4a** views/hooks/settings on the driver: `CallView.tsx` (owns
`RtcParticipationManager` when the crate path is selected), `InCallView.tsx`, `LobbyView.tsx`, `CallEndedView.tsx`,
`VideoPreview.tsx`, `useRoomInfo()` (replaces `useRoomName/Avatar/JoinRule/State`),
`InviteModal.tsx`, `Avatar.tsx`, `useOwnProfile.ts`, `ProfileSettingsTab.tsx`,
`SettingsModal.tsx`, `DeveloperSettingsTab.tsx`, `submit-rageshake.ts`,
`analytics/PosthogEvents.ts`, `controls.ts`, and a
first `CallView.stories.tsx` (lobby, in call, ended) driven by
`MockMatrixDriver`.
- **S4b ☑ (2026-09-16):** `ReactionsSenderProvider` takes `ownIdentifier`,
`ownMembershipEventId` and a `ReactionsTimeline` (`jsSdkReactionsTimeline`
over a client, or the client driver); `ParticipationReactionsReader`
(`src/reactions/`) reads hands and reactions from the participation and
the timeline driver, keyed by the member's media id
(`memberMediaId`, `src/state/rtc/mediaId.ts`), and re-resolves a hand on a
re-sent membership instead of dropping it blindly. Notifications went
through the driver in S3d. Tests: `ParticipationReactionsReader.test.ts`,
`useRtcParticipationManager.test.tsx`, three `CallView.test.tsx` cases for the
switch.
- **S4a views on the drivers (2026-09-16):** `CallView` now requires the
drivers (`useMatrixDrivers()`; both hosts provide them) and reads the
room through `useRoomInfo()` (`src/room/useRoomInfo.ts`; `useRoomAvatar`,
`useJoinRule`, `useRoomState` deleted, `useRoomName` stays for `RoomPage`),
our own profile through `useOwnProfile()` (`src/profile/useOwnProfile.ts`)
and the encryption system through `useEncryptionSystemFor(roomId,
roomInfo.encrypted)` (`useRoomEncryptionSystem` keeps the client for the
home page). `MatrixInfo` is built from those. `InviteModal` takes
`roomId`/`roomName`/`e2eeSystem`; `CallEndedView` lost its `client` prop;
`Avatar` resolves thumbnails through `clientDriver.thumbnailUrl` when
drivers are provided (host `downloadMedia` first, client fallback for the
shell); rageshakes carry the driver's `getDiagnostics()` as `driver_*`
fields and the crate's `matrix_rtc_snapshot`.
- **The component is client-free (2026-09-16):** `CallView`'s `client` and
`rtcSession` are optional; without them the crate carries the call
whatever the setting says (`useMatrixRtc = setting || no session`), and
every js-sdk-only piece (the `MembershipManagerError` listener, the room
sanity check, `ReactionsReader`, `mediaKeyStatisticsOf`) is skipped.
`ActiveCall`/`InCallView` take `roomId` instead of `matrixRoom`, and
`client`/`rtcSession` optionally. `ProfileSettingsTab` edits through the
client driver's optional `setDisplayName`/`setAvatar(file | null)` and
shows read-only fields without them; `DeveloperSettingsTab` reads the
sticky probe from `getMatrixClientFeatures()`, the crypto version from
`getDiagnostics()` and validates a custom LiveKit URL through
`rtcDriver.getLivekitToken` when there is no client; the rageshake request
event goes through the client driver's timeline; reactions are supported
unless a client state forbids them. `ElementCall` renders no
`ClientProvider` and no shim — it hands its two drivers down and nothing
below asks for a client; `initializeElementCall(config, { matrixRtcWasm })`
can preload the wasm. `PosthogEvents.eventCallEnded.track` takes
`MediaKeyStatistics` (from the session on the js-sdk path, zero on the
crate path until the crate counts). `useTypedEventEmitter` and the js-sdk
client driver's public `client`/`room` are gone. **Consequence:** the
component and its dev harness (`ElementCallClientBased`) now run every
call on the crate.
- **Banner dropped (2026-09-17):** the disconnected banner, its
`useHomeserverConnected` hook and the client state's `disconnected` flag
are gone. The local membership already carries the connection state, so
the reconnecting overlay covers the in-call case where it matters; in the
lobby the banner added little. `HomeserverConnected` in
`state/CallViewModel/localMember/` remains the one consumer of
`rtcDriver.isHomeserverConnected()` / `subscribeConnectivity` and of
`sync_disconnect_grace_period_ms`. The mock RTC driver keeps several
connectivity sinks (the crate's and the UI's).
- **S4 closed (2026-09-16):** `CallView.stories.tsx` (Lobby with a peer,
NoTransport as the error path, Ended) over the mock drivers, no client;
`.storybook/preview.tsx` initialises the config; the Storybook vitest
project passes. The Playwright switch is `CALL_VIEW_MODEL_IMPLEMENTATION`
read by `playwright.config.ts` into `use.storageState` (the developer
setting in local storage for every context), and the CI Playwright job is
a matrix over both implementations (`matrix-rtc` non-blocking until it
has passed once). `RtcParticipationManager.mediaKeyStatistics()` counts sent
and received keys and their age from the crate's key changes for the
ended-call event. Found on the way: `CallView` rendered `ActiveCall`
before the participation existed (wasm loads on first use) — it now waits;
and `onLeft` had gained the member count as a dependency, which rebuilt
the view model on every roster change on both paths — read through
`useLatest` now. `create-call.spec.ts` passes in Chromium on both
implementations with two view model creations each (StrictMode): the
crate path's first full call in a browser.
### S5 — hosts ☐ (component props done 2026-09-15)
- **Done:** `ElementCallProps` takes `rtcDriver` + `clientDriver` (`roomId`
optional, asserted equal); `ElementCallClientBased` takes `client` +
`roomId` and builds the two js-sdk drivers (the dev harness uses it); the
driver types and the js-sdk drivers are exported from the component index.
**Temporary:** until S4 the tree under `CallView` still runs on the
client, so `ElementCall` requires the client driver to be
`JsSdkElementCallMatrixClientDriver` (its `client`/`room` are public for
this) and throws for any other driver.
- `component/matrix-js-sdk.ts`, `component/package.json`
(`exports`, peers), `vite-component.config.ts` (two entries, `fileName`
function, externals shrink, `?url&no-inline`), `component/tsconfig.build.json`,
`component/dev/Harness.tsx`, `README.md`.
- `src/room/useLoadGroupCall.ts`, `RoomPage.tsx`, `src/widget.ts`
(capabilities, §4.5), `sdk/main.ts`, `playwright/spa-helpers.ts`
(delegation route helper), `docs/` config notes.
- Gate: all four builds; Playwright standalone + widget + component against
`pnpm backend`; a widget media-key round trip and `reconnect.spec.ts`
re-checked under the new delegation path.
### S6 — fence and cleanup ☐
- `src/utils/logger.ts`; local `CallIntent` type for the six `RTCCallIntent`
users; `useLocalStorage.ts` on a local emitter; oxlint
`no-restricted-imports` scoped to the call tree (allow-list: `src/home`,
`src/auth`, `src/utils/spa.ts`, `src/utils/matrix.ts`, `src/driver/jsSdk/**`,
`src/ClientContext.tsx`, `src/widget.ts`, `src/initializer.tsx`,
`src/IndexedDBWorker.ts`, `src/room/KnockLobbyView.tsx`, `src/settings/rageshake.ts`)
banning `matrix-js-sdk` except `matrix-js-sdk/lib/logger`.
- `ServiceInterruptionsViewModel` fed from `status$.impairments`.
- `callViewModelImplementation` setting, its `config.json` pin and the
Developer Settings control removed with `createJsClientCallViewModel$`
(§5.15); Playwright runs the suite once again.
- `docs/agents/architecture.md`, `docs/matrix_rtc_modes.md` updated;
`src/@types/matrix-js-sdk.d.ts` removed if nothing merges into js-sdk types.
---
## 7. Risks and mitigations
| Risk | Mitigation |
| ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ |
| A user without the power level to open a slot, in a room that never had a call | `NoOpenSlotError` names the remedy; a room admin opens the slot by starting the first call |
| A client on the removed sticky dialect (none is deployed) | Not supported: `Off` and `StateEvents` are the only dialects (C9) |
| Component bundle size / wasm delivery | `?url&no-inline` + `exports` wildcard + `matrixRtcWasm` override; verified in S0b and S5 |
| Token refresh vs LiveKit reconnect | `Connection.token$`; full reconnect uses the latest token; `expiresAtTs` logged |
| Own identity before the echo | C6 export; JWT `sub` only as a logged cross-check |
| Keys before identity | key provider buffers per member id and replays |
| Widget mode regressions (origins, to-device event, capabilities) | S1b tests against a `RoomWidgetClient` fake; S5 widget e2e adds a media-key round trip |
| Delegation fails after the long leave was armed | Arm-after-confirm (C5): the short leave stays armed until delegation is confirmed |
| Behaviour drift in keep-alive (6 s vs 4 s restarts) and dropped config keys | documented in `docs/` |
| Test time: wasm per file | lazy boot in the suites that need it only |
---
## 8. Verification matrix
| Check | S0a | S0b | S1 | S2 | S3 | S4 | S5 | S6 |
| ---------------------------------------------------------------------------- | --- | --- | --- | --- | --- | --- | --- | --- |
| `cargo test/clippy`, `web-test-app` suites | ● | | | | | | | |
| `pnpm lint` + `format:check` | | ● | ● | ● | ● | ● | ● | ● |
| `pnpm test:unit` | | ● | ● | ● | ● | ● | ● | ● |
| `pnpm test:storybook` | | | | | | ● | ● | ● |
| four builds | | ● | | | | | ● | ● |
| Playwright standalone + widget + component | | | | | | | ● | ● |
| Manual: two harness panes hear each other, E2EE, hand raise, reaction, leave | | | | | | | ● | |
| Real backend (`RtcParticipationManager.backend.test.ts`, opt-in, both modes) | | | | ● | | | ● | |
---
## 9. Open questions for the user (answered by the assumptions in §5 until told otherwise)
1. Slot semantics, **answered**: no slot means no call; Element Call opens the
slot on the first call in a room, power level permitting.
2. Delegation, **answered**: CS API endpoint first, then Element Call's
OpenID → JWT → scheduled-event path, all inside the crate and invisible to
Element Call (C5).
3. Media-key type under the sticky compat, **answered**: there are no deployed
sticky-event clients, so the compat mode is removed (C9) and the question
with it.
4. MSC4153, **answered**: off everywhere for now, with a TODO to turn it on
and C10 so tiles show unverified senders in the meantime.
5. Reactions relation target: current membership event id (taken), stable join
event id, or `memberId`?
6. Committing the 6.5 MB wasm (taken) vs a build-time fetch.
7. `roomId` prop: optional for one release (taken) vs removed outright.
8. `updateCallIntent`, **answered**: added to the crate as `update_application`
(C11); Element Call keeps the behaviour.
---
## 10. Review log
Independent review findings incorporated in this revision: slot enforcement
blocker (C1); delegation protocol and 1 h arm (C5, §5.9; later redefined as crate-only with arm-after-confirm); `StickyEvents` key
type interop (C3, then made moot by removing the mode, C9); MSC4153 default (§5.8); widget-client differences for the
js-sdk drivers (§4.6) and missing widget capabilities (§4.5); own identity
export (C6); `RtcParticipationManager` lifetime at `CallView` (§4.2); notification
timing and content (§4.3); reactions/event-id semantics (§5.7); config mapping
defaults and dropped keys (§4.3); `bigint`/`ArrayBuffer` types; inventory
gaps (§2); knip `ignore` vs `ignoreFiles`, oxlint/oxfmt ignores, `.d.ts` for
the glue, lazy wasm boot, Storybook `beforeAll`; slice re-cut (S0a/b, S1a/b,
S3a–d, S4a/b, temporary `CallView` shim); `sdk/main.ts` status wiring and the
Playwright delegation helper.
**Real-backend check (2026-09-15), `pnpm backend` + `src/state/rtc/RtcParticipationManager.backend.test.ts`**
(`MATRIX_RTC_BACKEND=1 NODE_TLS_REJECT_UNAUTHORIZED=0 pnpm vitest run --project unit src/state/rtc/RtcParticipationManager.backend.test.ts`;
two registered users with rust crypto in an encrypted room, `matrix_2_0` and
`compatibility`): passes end to end — transport discovery from
`/rtc/transports`, slot open + echo, sticky member event with `msc4354_sticky`,
delayed leave, delegation via the homeserver, roster with display names,
Olm-encrypted media keys both ways with `senderCrossSigned`, leave cancelling
the delayed event, and `HomeserverUnreachable` raised and cleared across a
simulated network outage. Fixed on the way: MSC4195 `member` claims and the
homeserver route's body (`url`, `delay_timeout`; C5), the missing session
listener (C13), the legacy slot id under compatibility mode (§5.13). Noted:
`vite-plugin-node-polyfills` shadows `process`, so tests read the environment
through `node:process`; Synapse develop already proxies the MSC4195 endpoint
to lk-jwt-service; member events are Megolm-encrypted in encrypted rooms
(§5.14). Not verified here: the widget host path (Element Web) and the
authorisation-service route (the homeserver route is taken first on this
stack; it is covered by unit tests only).
**S3 (2026-09-15):** the driver-based `createCallViewModel$` landed next to
the renamed `createJsClientCallViewModel$` (shared `assembleCallViewModel`,
neutral `CallMember`/`DeclineEvent`, `LocalMedia.ts` extracted); component
props moved to the two drivers with `ElementCallClientBased` on top. Gates:
`pnpm lint`, `format:check`, `test:unit` (803), `i18n:check`,
`build:component` green. Open from this slice: reactions reader on the
participation (S4b), the `?url&no-inline` wasm asset for the component build
(the wasm is loaded lazily and inlined into the component bundle today), and
the S6 deletions.
**S4 (2026-09-16):** the implementation switch (§5.15) is in with its
config pin, developer control and rageshake field; `CallView` owns a
`RtcParticipationManager` when the crate path is selected and `ActiveCall` builds
the matching view model; the reactions reader and sender have participation
and driver counterparts. Both paths run in the same build. Gates: `pnpm
lint`, `format:check`, `i18n:check`, `test:unit` (815), `build:component`
green. Not verified: a real call on the crate path through the UI (the
`pnpm backend` check covers the view model's Matrix side; the browser run is
the S5 gate).
**Logging (2026-09-16):** C14 — the crate's log lines reach the host through
a `LogSink`; Element Call routes them to its rageshake logger under
`[matrix-rtc]`, the web-test-app to the console. Verified by a unit test that
sees the crate's "session created" line through the sink.
**S4 views (2026-09-16):** room info, own profile, encryption system,
avatars, the invite and ended views and the rageshake fields read the
drivers; `CallView` requires drivers. Three room hooks deleted. Gates:
`pnpm lint`, `format:check`, `test:unit` (819), `build:component` green.
**Client-free component (2026-09-16):** `CallView` and everything under it
run without a matrix-js-sdk client on the crate path; the component passes
only its drivers. Gates: `pnpm lint`, `format:check`, `test:unit` (819),
`build:component` green. Not yet done: a browser run of the component on
the crate path (the dev harness now is that run).
**S4 done (2026-09-16):** stories, Playwright switch and CI matrix, key
statistics. `create-call.spec.ts` green on `matrix-js-sdk` and `matrix-rtc`
in Chromium against the dev server; the rest of the suite under
`matrix-rtc` is the S5 gate. Gates: `pnpm lint`, `format:check`,
`test:unit` (822), `test:storybook` (28), `build:component` green.
-6
View File
@@ -26,12 +26,6 @@ export default {
// This is a shell built-in.
"printf",
],
ignore: [
// The host-facing driver seam, landed ahead of its consumers (the
// `CallParticipation` layer and the hosts, see element-call-oxidation-plan.md)
"src/driver/**",
"src/state/rtc/**",
],
ignoreFiles: [
"scripts/.pnpmfile.cjs",
// Deliberately added prior to any component or business logic
+22 -14
View File
@@ -3,6 +3,10 @@
"user_menu": "User menu"
},
"action": {
"add_background_image": "Add image",
"background_effect_blur": "Blur",
"background_effect_none": "None",
"background_effect_numbered": "Background {{n}}",
"blur_background": "Blur background",
"close": "Close",
"copy_link": "Copy link",
@@ -23,6 +27,9 @@
"upload_file": "Upload file"
},
"analytics_notice": "By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <6>Cookie Policy</6>.",
"background_effects": {
"slow_in_this_browser": "This browser runs background effects slowly, so other people may see your video stutter."
},
"call_ended_view": {
"create_account_button": "Create account",
"create_account_prompt": "<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>",
@@ -58,18 +65,6 @@
},
"developer_mode": {
"always_show_iphone_earpiece": "Show iPhone earpiece option on all platforms",
"callViewModelImplementation": {
"forced": "Your deployment pins the call implementation.",
"matrix_js_sdk": {
"description": "The MatrixRTC session of matrix-js-sdk carries the call, as before",
"label": "matrix-js-sdk"
},
"matrix_rtc": {
"description": "The Rust matrix-rtc crate carries the call through the host's drivers",
"label": "matrix-rtc (Rust)"
},
"title": "Call implementation"
},
"crypto_version": "Crypto version: {{version}}",
"custom_livekit_url": {
"current_url": "Currently set to: ",
@@ -102,7 +97,12 @@
"show_connection_stats": "Show connection statistics",
"url_params": "URL parameters"
},
"disconnected_banner": "Connectivity to the server has been lost.",
"error": {
"background_animated": "Animated images cannot be used as a background",
"background_not_an_image": "That file is not an image",
"background_not_kept": "That background could not be saved",
"background_undecodable": "That image could not be opened",
"call_is_not_supported": "Call is not supported",
"call_not_found": "Call not found",
"call_not_found_description": "<0>That link doesn't appear to belong to any existing call. Check that you have the right link, or <2>create a new one</2>.</0>",
@@ -121,8 +121,6 @@
"membership_manager": "Membership Manager Error",
"membership_manager_description": "The Membership Manager had to shut down. This is caused by many consecutive failed network requests.",
"no_matrix_2_authorization_service": "The authorization service for your media server (SFU) is out of date.",
"no_open_slot": "No call to join here",
"no_open_slot_description": "Nobody has started a call in this room yet, and you do not have permission to start one. Ask a room admin to start the call, or to allow you to start calls.",
"open_elsewhere": "Opened in another tab",
"open_elsewhere_description": "{{brand}} has been opened in another tab. If that doesn't sound right, try reloading the page.",
"peer_connection_timeout": "Connection timeout",
@@ -176,6 +174,12 @@
"login_auth_links_prompt": "Not registered yet?",
"login_subheading": "To continue to Element",
"login_title": "Login",
"microphone_level": {
"label": "Microphone level",
"no_device": "No microphone found. Connect one, then try again.",
"permission_denied": "Element Call cannot use your microphone. Allow access in your browser settings.",
"value": "{{level}} of {{max}}"
},
"microphone_off": "Microphone off",
"microphone_on": "Microphone on",
"mute_microphone_button_label": "Mute microphone",
@@ -219,6 +223,7 @@
"auto_gain_control_label": "Automatic gain control",
"background_blur_header": "Background",
"background_blur_label": "Blur the background of the video",
"background_effects_header": "Background effects",
"bitrate_label": "Bitrate",
"blur_not_supported_by_browser": "(Background blur is not supported by this device.)",
"camera_header": "Camera quality",
@@ -228,11 +233,14 @@
"activating": "Activating…",
"camera": "Camera",
"camera_numbered": "Camera {{n}}",
"camera_source": "Camera Source",
"change_device_button": "Change audio device",
"default": "Default",
"default_named": "Default <2>({{name}})</2>",
"default_named_plain": "Default ({{name}})",
"handset": "Handset",
"loudspeaker": "Loudspeaker",
"mic_source": "Mic Source",
"microphone": "Microphone",
"microphone_numbered": "Microphone {{n}}",
"speaker": "Speaker",
-1
View File
@@ -48,7 +48,6 @@
},
"devDependencies": {
"@codecov/vite-plugin": "^1.3.0",
"@element-hq/matrix-rtc": "npm:@billcarsonfr/matrix-rtc@next",
"@fontsource/inconsolata": "^5.1.0",
"@fontsource/inter": "^5.1.0",
"@formatjs/intl-durationformat": "^0.10.0",
-27
View File
@@ -19,32 +19,6 @@ const baseURL = process.env.USE_DOCKER
const __dirname = path.dirname(fileURLToPath(import.meta.url));
/**
* Which MatrixRTC implementation carries the calls under test, `matrix-js-sdk`
* or `matrix-rtc` (see `CallViewModelImplementation` in
* src/config/ConfigOptions.ts). Unset, Element Call's default applies. Set,
* every browser context starts with the developer setting in local storage,
* so the whole suite runs on that implementation.
*/
const callViewModelImplementation = process.env.CALL_VIEW_MODEL_IMPLEMENTATION;
const storageState =
callViewModelImplementation === undefined
? undefined
: {
cookies: [],
origins: [
{
origin: baseURL,
localStorage: [
{
name: "matrix-setting-call-view-model-implementation",
value: JSON.stringify(callViewModelImplementation),
},
],
},
],
};
// Needed by the synapse admin API called in fixtures
process.env.NODE_EXTRA_CA_CERTS = join(
__dirname,
@@ -70,7 +44,6 @@ export default defineConfig({
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL,
storageState,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
+253
View File
@@ -0,0 +1,253 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, test, type Locator, type Page } from "@playwright/test";
import { SpaHelpers } from "./spa-helpers.ts";
import { installFakeDevices } from "./utils/fake-devices.ts";
test.describe("the quick audio menu", () => {
test("lists speakers and microphones with a live level meter", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 3, speakers: 3 });
await joinACall(page, "Menu user", "Audio menu");
await openAudioMenu(page);
// The speaker list is what the settings modal used to be the only home of.
await expect(page.getByRole("group", { name: "Speaker" })).toBeVisible();
await expect(page.getByRole("group", { name: "Microphone" })).toBeVisible();
// Named rather than counted: the browser contributes its own fake output
// and a "Default" entry, so a total would be a fact about the browser.
for (const n of [1, 2, 3])
await expect(
page
.getByRole("group", { name: "Speaker" })
.getByRole("menuitemradio", { name: `Fake Speaker ${n}` }),
).toBeVisible();
// Only one entry of a kind is marked, and the meter reports a number
// rather than a colour.
await expect(
page.getByRole("menuitemradio", { checked: true }),
).toHaveCount(2);
const meter = page.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
await expect(meter).toHaveAttribute("aria-valuenow", /\d+/);
await expect(meter).toHaveAttribute("aria-valuetext", /\d+ of \d+/);
// Both browsers in the matrix can route audio to a chosen output, so the
// section offers a real choice. The case where a platform cannot — Safari,
// and anything without setSinkId — is covered by a unit check, since no
// browser here can reach it.
await expect(
page
.getByRole("group", { name: "Speaker" })
.getByRole("menuitemradio")
.first(),
).toHaveAttribute("aria-disabled", "false");
});
test("moves the microphone and the speaker without disturbing the call", async ({
browser,
}) => {
// Two browsers, two joins and a real call between them.
test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage();
await installFakeDevices(host, { microphones: 3, speakers: 3 });
await joinACall(host, "Host", "Device switch");
const inviteLink = await SpaHelpers.getCallInviteLink(host);
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guest = await guestContext.newPage();
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Guest");
await SpaHelpers.expectVideoTilesCount(guest, 2);
await openAudioMenu(host);
await selectDevice(host, "Microphone", "Fake Microphone 2");
await openAudioMenu(host);
await selectDevice(host, "Speaker", "Fake Speaker 2");
// The point of the criterion: the switch is not a rejoin. Neither side
// sees the call drop, and the guest still has both tiles — so the host
// never left and came back.
await expect(
host.getByRole("dialog", { name: "Reconnecting…" }),
).not.toBeVisible();
await expect(
guest.getByRole("dialog", { name: "Reconnecting…" }),
).not.toBeVisible();
await SpaHelpers.expectVideoTilesCount(guest, 2);
await expect(guest.getByText("Waiting for media...")).not.toBeVisible();
await hostContext.close();
await guestContext.close();
});
test("keeps the meter moving while muted, and sends nothing", async ({
browser,
}) => {
// Two browsers, two joins and a real call between them.
test.slow();
const hostContext = await browser.newContext({ reducedMotion: "reduce" });
const host = await hostContext.newPage();
await installFakeDevices(host);
await joinACall(host, "Muted host", "Muted meter");
const inviteLink = await SpaHelpers.getCallInviteLink(host);
const guestContext = await browser.newContext({ reducedMotion: "reduce" });
const guest = await guestContext.newPage();
await SpaHelpers.joinCallFromInviteLink(guest, inviteLink, "Listener");
await SpaHelpers.expectVideoTilesCount(guest, 2);
const mute = host.getByTestId("incall_mute");
await mute.click();
await expect(mute).toHaveAttribute("aria-checked", "false");
await openAudioMenu(host);
// The microphone is held open while muted, so the meter still reports the
// hardware. The mute control is what says nothing is being transmitted.
const meter = host.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
// Queried by test id, not by role: the menu is modal, so Radix takes the
// rest of the call out of the accessibility tree while it is open.
await expect(mute).toHaveAttribute("aria-checked", "false");
await expect(mute).toBeVisible();
// And the listener is told so, rather than being left to guess from silence.
await expect(
guest.getByTestId("videoTile").filter({ hasText: "Muted host" }),
).toBeVisible();
await hostContext.close();
await guestContext.close();
});
test("keeps the meter at the foot of the list while it scrolls", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
await joinACall(page, "Scroller", "Long device list");
await openAudioMenu(page);
const meter = page.getByRole("meter", { name: "Microphone level" });
await expect(meter).toBeVisible();
// Scrolled so the microphones start at the top of the list and run past its
// bottom: the position that tells a pinned meter from one that merely
// happens to be last.
const list = page.locator("[role='menu'] div[role='none']").first();
await list.evaluate((element) => {
const group = element.querySelector("[role='group'][aria-label*='icro']");
element.scrollTop +=
group!.getBoundingClientRect().top -
element.getBoundingClientRect().top;
});
await expect(meter).toBeInViewport();
await expectPinnedInside(meter, list);
// Every entry stays reachable, which is what the scroll is for.
await expect(
page.getByRole("menuitemradio", { name: "Fake Microphone 20" }),
).toBeVisible();
});
test("shows the focus ring only when the keyboard moved the focus", async ({
page,
browserName,
}) => {
test.skip(
browserName === "firefox",
"Headless Firefox does not deliver synthetic key presses reliably; see reconnect.spec.ts",
);
await installFakeDevices(page);
await joinACall(page, "Keyboard user", "Focus ring");
await openAudioMenu(page);
const first = page.getByRole("menuitemradio").first();
// Opened by pointer, so no ring, even though Radix has moved focus into the
// menu already.
await expect.poll(async () => outlineWidth(first)).toBe(0);
await page.keyboard.press("ArrowDown");
const focused = page.locator("[role='menuitemradio']:focus");
await expect.poll(async () => outlineWidth(focused)).toBeGreaterThan(0);
// The pointer takes it away again: the menu focuses whatever it is over, so
// a ring that followed focus alone would trail the mouse.
await first.hover();
await expect.poll(async () => outlineWidth(focused)).toBe(0);
});
});
/** Creates a call and joins it, leaving the page in the call. */
async function joinACall(
page: Page,
userName: string,
callName: string,
): Promise<void> {
await page.goto("/");
await SpaHelpers.createCall(page, userName, callName, true);
await expect(page.getByTestId("name_tag")).toContainText(userName);
// The media controls stay disabled until the devices have enumerated, and
// every test here drives them.
await expect(page.getByTestId("incall_mute")).toBeEnabled({
timeout: 10_000,
});
}
async function openAudioMenu(page: Page): Promise<void> {
await page.getByRole("button", { name: "Microphone" }).click();
await expect(page.getByRole("menu")).toBeVisible();
}
async function selectDevice(
page: Page,
section: "Speaker" | "Microphone",
name: string,
): Promise<void> {
const item = page
.getByRole("group", { name: section })
.getByRole("menuitemradio", { name });
await item.click();
// Selecting does not close the menu — the component prevents the default so
// the list survives a mis-click — so it is dismissed explicitly.
await page.keyboard.press("Escape");
await expect(page.getByRole("menu")).not.toBeVisible();
}
/**
* Asserts the meter sits within the scrollport, and inside the menu's frame.
*
* The meter is the one opaque element in the menu, so it is the one thing that
* can paint over the border. Whether it actually does needs a screenshot; this
* pins the geometry that decides it.
*/
async function expectPinnedInside(
meter: Locator,
list: Locator,
): Promise<void> {
const meterBox = (await meter.boundingBox())!;
const listBox = (await list.boundingBox())!;
const frame = (await meter.page().getByRole("menu").boundingBox())!;
expect(meterBox.y + meterBox.height).toBeLessThanOrEqual(
listBox.y + listBox.height + 1,
);
expect(meterBox.y).toBeGreaterThanOrEqual(listBox.y - 1);
expect(meterBox.x).toBeGreaterThan(frame.x);
expect(meterBox.x + meterBox.width).toBeLessThan(frame.x + frame.width);
}
/** The painted outline width in pixels, however the stylesheet spells it. */
async function outlineWidth(item: Locator): Promise<number> {
if ((await item.count()) === 0) return 0;
return item.first().evaluate((element) => {
const { outlineStyle, outlineWidth } = getComputedStyle(element);
return outlineStyle === "none" ? 0 : Number.parseFloat(outlineWidth) || 0;
});
}
+165
View File
@@ -0,0 +1,165 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, type Locator, type Page, test } from "@playwright/test";
import { createUserAndRoom, resizeContainer, startHarness } from "./harness.ts";
import { installFakeDevices } from "../utils/fake-devices.ts";
/**
* The device menu where Element Call is a component in a host's page rather
* than the whole of one.
*
* This is the case the stylesheets cannot describe: the menu is portalled to
* the document, so a container query and a viewport unit both measure the wrong
* thing — the first has no container to resolve against out there, the second
* measures a page Element Call does not own. The menu has to be sized against
* the space the call is actually drawn in.
*
* Driven from the lobby rather than a joined call. The footer builds the same
* menu from the same device behaviours in both, and the container is the same
* size either way, so joining would only add two connections' worth of flake.
*/
// Signing in, setting up crypto and syncing happen twice before anything is on
// screen, as in component-call.spec.ts
test.describe.configure({ timeout: 180_000 });
test("sizes the device list against the call, not the window", async ({
page,
}) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menusize");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
// A short call in a much taller page: the difference between measuring the
// call and measuring the window.
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 900, height: 400 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
const callHeight = (await container.boundingBox())!.height;
const windowHeight = page.viewportSize()!.height;
const listHeight = (await list.boundingBox())!.height;
// Sized against the call. Were it sized against the window the list would be
// half as tall again, and the assertion below would not be able to tell.
expect(callHeight).toBeLessThan(windowHeight * 0.75);
expect(listHeight).toBeLessThanOrEqual(callHeight);
expect(listHeight).toBeLessThan(windowHeight * 0.6);
});
test("follows the call area when the host resizes it", async ({ page }) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menuresize");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
await resizeContainer(container, { width: 900, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
const whenShort = (await list.boundingBox())!.height;
// The host grows the space Element Call is drawn in while the menu is open —
// a panel opening, a window dragged, a phone turned. A bound taken once on
// opening would still describe the smaller call.
await resizeContainer(container, { width: 900, height: 700 });
await expect
.poll(async () => (await list.boundingBox())!.height)
.toBeGreaterThan(whenShort);
});
test("keeps every device reachable in a small container", async ({ page }) => {
await installFakeDevices(page, { microphones: 20, speakers: 4 });
const { username, roomId } = await createUserAndRoom("menureach");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const container = pane.getByTestId("call-container");
// Narrow as well as short, which is where entries get pushed out of reach.
await resizeContainer(container, { width: 400, height: 360 });
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
const list = await openDeviceList(page, pane);
// More devices than the space allows, so the list has to scroll rather than
// put entries somewhere they cannot be got at.
expect(await list.evaluate((el) => el.scrollHeight > el.clientHeight)).toBe(
true,
);
const last = page.getByRole("menuitemradio", { name: "Fake Microphone 20" });
await last.scrollIntoViewIfNeeded();
await expect(last).toBeInViewport();
// Reachable means usable, not merely painted: D11 accepts that the menu may
// be drawn outside the call area, so this asserts reach rather than
// containment.
await last.click();
await expect(last).toHaveAttribute("aria-checked", "true");
});
test("tracks the focus modality of its own call, not the page", async ({
page,
}) => {
await installFakeDevices(page);
const { username, roomId } = await createUserAndRoom("menufocus");
const panes = await startHarness(page, username, roomId);
const pane = panes.first();
const other = panes.nth(1);
await expect(pane.getByTestId("lobby_joinCall")).toBeVisible({
timeout: 60_000,
});
await openDeviceList(page, pane);
// The menu owns the modality, because every item it can focus has to answer
// to it — the device rows and the camera menu's blur toggle alike.
const menu = page.getByRole("menu");
// Asserted on the attribute rather than the painted ring, which cannot be
// read here: the menu is portalled outside the call root, and the component
// build scopes the stylesheet to it, so neither the ring nor the rule that
// suppresses the browser's own reaches this menu. The paint is asserted
// standalone instead — in the story and in audio-menu.spec.ts. What is on
// trial here is which call the tracking answers for.
await expect(menu).toHaveAttribute("data-focus-modality", "pointer");
// A key pressed in the other call on this page — or anywhere in the host's
// own page — says nothing about how this menu is being used.
await other.evaluate((element) =>
element.dispatchEvent(
new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }),
),
);
await expect(menu).toHaveAttribute("data-focus-modality", "pointer");
// A key pressed in this menu does.
await page.keyboard.press("ArrowDown");
await expect(menu).toHaveAttribute("data-focus-modality", "keyboard");
});
/**
* Opens the microphone menu of one component and returns its scrolling device
* list, which lives outside the component: the menu is portalled to the page.
*/
async function openDeviceList(page: Page, pane: Locator): Promise<Locator> {
await pane
.getByRole("button", { name: "Microphone" })
.click({ timeout: 60_000 });
await expect(page.getByRole("menu")).toBeVisible();
const list = page.locator("[role='menu'] div[role='none']").first();
await expect(list).toBeVisible();
return list;
}
+13 -4
View File
@@ -109,10 +109,15 @@ export const widgetTest = test.extend<MyFixtures>({
await TestHelpers.dismissInviteUnknownUserModal(ewPage1);
// Accept the invite
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await expect(
ewPage2.getByRole("option", { name: "Welcome Room" }),
TestHelpers.roomListItem(ewPage2, "Welcome Room"),
).toBeVisible();
await ewPage2.getByRole("option", { name: "Welcome Room" }).click();
await TestHelpers.roomListItem(ewPage2, "Welcome Room").click();
await ewPage2.getByRole("button", { name: "Accept" }).click();
await expect(
ewPage2
@@ -152,8 +157,12 @@ export const widgetTest = test.extend<MyFixtures>({
).toBeVisible();
// Accept the DM invite from brooks
// This how playwright record selects the DM invite in the room list
await ewPage2.getByRole("button", { name: "Open room" }).click();
await TestHelpers.closeReleaseAnnouncement(
ewPage2,
"Introducing Sections",
);
await TestHelpers.expandAllSections(ewPage2);
await TestHelpers.roomListItem(ewPage2, brooksDisplayName).click();
await ewPage2.getByRole("button", { name: "Start chatting" }).click();
}
+98
View File
@@ -0,0 +1,98 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Page } from "@playwright/test";
/**
* Gives the browser more fake devices than it ships with.
*
* A headless browser's fake capture offers one microphone and one speaker,
* which is one short of what a device menu is for: with no choice to make, every
* entry renders disabled. These are synthetic entries on top of the real fake
* device, so the menu has a list to show and a selection to move, and the app
* runs its real device pipeline against them.
*
* What they do not do is route audio: every entry is backed by the same capture,
* and `setSinkId` is accepted rather than honoured. A test can prove that
* choosing a device changes the app's state and does not disturb the call. That
* a listener hears the change needs hardware, and stays a manual check.
*
* Must be called before the page navigates.
*/
export async function installFakeDevices(
page: Page,
{ microphones = 2, speakers = 2 } = {},
): Promise<void> {
await page.addInitScript(
({ microphones, speakers }) => {
const synthetic = (
kind: MediaDeviceKind,
count: number,
name: string,
): MediaDeviceInfo[] =>
Array.from({ length: count }, (_, i) => {
const info = {
deviceId: `${kind}-${i + 1}`,
groupId: `${kind}-group-${i + 1}`,
kind,
label: `${name} ${i + 1}`,
};
return { ...info, toJSON: () => info } as MediaDeviceInfo;
});
const ids = new Set(
[
...synthetic("audioinput", microphones, ""),
...synthetic("audiooutput", speakers, ""),
].map((d) => d.deviceId),
);
const devices = navigator.mediaDevices;
const enumerate = devices.enumerateDevices.bind(devices);
devices.enumerateDevices = async (): Promise<MediaDeviceInfo[]> => [
...(await enumerate()),
...synthetic("audioinput", microphones, "Fake Microphone"),
...synthetic("audiooutput", speakers, "Fake Speaker"),
];
// Our ids name no hardware, so an exact-device constraint on one would be
// rejected. Drop it and let the one real fake device answer.
const getUserMedia = devices.getUserMedia.bind(devices);
devices.getUserMedia = async (
constraints?: MediaStreamConstraints,
): Promise<MediaStream> => {
const audio = constraints?.audio;
if (typeof audio === "object") {
const requested = audio.deviceId;
const id =
typeof requested === "object" && requested !== null
? ((requested as ConstrainDOMStringParameters).exact as string)
: (requested as string | undefined);
if (id !== undefined && ids.has(id))
return getUserMedia({ ...constraints, audio: true });
}
return getUserMedia(constraints);
};
// Routing to a device that does not exist would reject, and the app
// treats that as a failed switch. Both sinks are patched: Element Call
// routes its own AudioContext as well as the media elements, and leaving
// that one alone logs a NotFoundError for every switch.
for (const proto of [
HTMLMediaElement.prototype,
AudioContext.prototype,
]) {
const sink = proto as { setSinkId?: (id: string) => Promise<void> };
const setSinkId = sink.setSinkId;
if (setSinkId === undefined) continue;
sink.setSinkId = async function (id: string): Promise<void> {
if (!ids.has(id)) await setSinkId.call(this, id);
};
}
},
{ microphones, speakers },
);
}
+27 -4
View File
@@ -11,6 +11,7 @@ import {
type JSHandle,
type Page,
type FrameLocator,
type Locator,
} from "@playwright/test";
import { type MatrixClient } from "matrix-js-sdk";
@@ -203,6 +204,16 @@ export class TestHelpers {
}
}
public static async expandAllSections(page: Page): Promise<void> {
try {
await page
.getByRole("button", { name: "Expand all sections" })
.click({ timeout: 2000 });
} catch {
// Already expanded or button not present
}
}
public static async createRoom(
name: string,
page: Page,
@@ -253,9 +264,9 @@ export class TestHelpers {
roomName: string,
page: Page,
): Promise<void> {
await page.getByRole("option", { name: roomName }).click({
timeout: 10000,
});
await TestHelpers.closeReleaseAnnouncement(page, "Introducing Sections");
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click({ timeout: 10000 });
await page.getByRole("button", { name: "Accept" }).click({
timeout: 5000,
});
@@ -350,6 +361,17 @@ export class TestHelpers {
.click();
}
/**
* Locates a room in the room list by its name.
*
* Matches on the aria-label prefix because the item's role differs between
* the flat room list (`option`) and the sectioned room list (`button`), and
* the label may carry a suffix such as " invitation.".
*/
public static roomListItem(page: Page, roomName: string): Locator {
return page.locator(`[aria-label^="Open room ${roomName}"]`);
}
/**
* Switches to a room in the room list by its name.
* @param page - The EW page
@@ -359,7 +381,8 @@ export class TestHelpers {
page: Page,
roomName: string,
): Promise<void> {
await page.getByRole("option", { name: `Open room ${roomName}` }).click();
await TestHelpers.expandAllSections(page);
await TestHelpers.roomListItem(page, roomName).click();
}
public static async dismissInviteUnknownUserModal(page: Page): Promise<void> {
-20
View File
@@ -25,9 +25,6 @@ importers:
'@codecov/vite-plugin':
specifier: ^1.3.0
version: 1.9.1(vite@8.2.2(@types/node@24.13.3)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.103.1)(terser@5.46.1)(yaml@2.9.0))
'@element-hq/matrix-rtc':
specifier: npm:@billcarsonfr/matrix-rtc@next
version: '@billcarsonfr/matrix-rtc@0.1.0-next.7.c5a5a80(matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a)'
'@fontsource/inconsolata':
specifier: ^5.1.0
version: 5.3.0
@@ -412,13 +409,6 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@billcarsonfr/matrix-rtc@0.1.0-next.7.c5a5a80':
resolution: {integrity: sha512-6Ti4jQZahHPI2Y9F/6dUplSEvoDdWP0OTLJXFUe708YFa13nRwW3t7QoRRbsSB5O0eWQHwu9hHVJQHEjpx4EwA==, tarball: https://npm.pkg.github.com/download/@billcarsonfr/matrix-rtc/0.1.0-next.7.c5a5a80/55919d699c12752b2316ad6d97a00c4b78088057}
version: 0.1.0-next.7.c5a5a80
engines: {node: '>=20'}
peerDependencies:
matrix-js-sdk: '>=42'
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
@@ -3228,9 +3218,6 @@ packages:
resolution: {integrity: sha512-+rmdgPA+EXkNgKYvHvFfhrs35utXbwaC5PGpDquSXcoXQDKUA5UjV0LmTucG/4JXkM31BTu4TilHtrN8IVBe8w==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@ubjs/core@0.31.0-5':
resolution: {integrity: sha512-oRBRtyYOhaodiOY3rLMZFgAHGFMmD44XefMpsAx4Ja0/rO+45kVdQB1VZsblQMLYxP965i3IHm56eymgo3I5eQ==}
'@ungap/structured-clone@1.3.3':
resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==}
@@ -6372,11 +6359,6 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@billcarsonfr/matrix-rtc@0.1.0-next.7.c5a5a80(matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a)':
dependencies:
'@ubjs/core': 0.31.0-5
matrix-js-sdk: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/24929be0e741be6a5d0a7226f1c682e245263b8a
'@blazediff/core@1.9.1': {}
'@bufbuild/protobuf@1.10.1': {}
@@ -8704,8 +8686,6 @@ snapshots:
'@typescript-eslint/types': 8.69.0
eslint-visitor-keys: 5.0.1
'@ubjs/core@0.31.0-5': {}
'@ungap/structured-clone@1.3.3': {}
'@use-gesture/core@10.3.1': {}
-2
View File
@@ -3,8 +3,6 @@ supportedArchitectures:
cpu: [current, arm64]
libc: [current, glibc]
minimumReleaseAgeExclude:
# The MatrixRTC SDK is consumed as `next` pre-releases straight from its CI
- "@billcarsonfr/matrix-rtc"
- "@vector-im/compound-design-tokens"
- "@vector-im/compound-web"
- matrix-js-sdk
+5 -5
View File
@@ -31,6 +31,7 @@ import {
tap,
} from "rxjs";
import {
type CallMembership,
MatrixRTCSessionEvent,
MatrixRTCSessionManager,
} from "matrix-js-sdk/lib/matrixrtc";
@@ -47,7 +48,7 @@ import { type TextStreamInfo } from "../node_modules/livekit-client/dist/src/roo
import { type Behavior, constant } from "../src/state/Behavior";
import {
callViewModelOptionsFromParams,
createJsClientCallViewModel$,
createCallViewModel$,
} from "../src/state/CallViewModel/CallViewModel";
import { ObservableScope } from "../src/state/ObservableScope";
import { getUrlParams } from "../src/UrlParams";
@@ -58,7 +59,6 @@ import { currentAndPrev, TEXT_LK_TOPIC, tryMakeSticky } from "./helper";
import { logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { initializeWidget } from "../src/widget";
import { type Connection } from "../src/state/CallViewModel/remoteMembers/Connection";
import { type CallMember } from "../src/state/CallViewModel/remoteMembers/MatrixLivekitMembers";
import { createWidgetHostBridge } from "../src/HostBridge";
import { observeElementSize$ } from "../src/utils/elementSize";
@@ -83,7 +83,7 @@ interface MatrixRTCSdk {
remoteMembers$: Behavior<
{
connection: Connection | null;
membership: CallMember;
membership: CallMembership;
participant: LocalParticipant | RemoteParticipant | null;
}[]
>;
@@ -92,7 +92,7 @@ interface MatrixRTCSdk {
*/
localMember$: Behavior<{
connection: Connection | null;
membership: CallMember;
membership: CallMembership;
participant: LocalParticipant | null;
} | null>;
/** Use the LocalMemberConnectionState returned from `join` for a more detailed connection state */
@@ -142,7 +142,7 @@ export async function createMatrixRTCSdk(
);
// call view model
const callViewModel = createJsClientCallViewModel$(
const callViewModel = createCallViewModel$(
scope,
rtcSession,
room,
+11
View File
@@ -22,6 +22,8 @@ import {
} from "react-router-dom";
import * as Sentry from "@sentry/react";
import { TooltipProvider } from "@vector-im/compound-web";
import { BackgroundEffectsBench } from "./livekit/BackgroundEffectsBench";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk";
import { I18nextProvider } from "react-i18next";
@@ -156,6 +158,15 @@ export const App: FC<Props> = ({ vm, widget }) => {
<SentryRoute path="/" element={<HomePage />} />
<SentryRoute path="/login" element={<LoginPage />} />
<SentryRoute path="/register" element={<RegisterPage />} />
{/* Exploration only: measures whether this device can run
background effects, on a device the app refuses to run them
on. One path segment, not two: the config is fetched
relative to the page, so a nested path looks for it in a
directory that does not exist and the app never starts. */}
<SentryRoute
path="/background-effects-bench"
element={<BackgroundEffectsBench />}
/>
<SentryRoute path="*" element={<RoomPage />} />
</Routes>
</Sentry.ErrorBoundary>
+1
View File
@@ -29,6 +29,7 @@ const TestComponent: FC<
<ClientContextProvider
value={{
state: "valid",
disconnected: false,
supportedFeatures: {
reactions: true,
},
+9 -28
View File
@@ -16,7 +16,6 @@ import { Avatar as CompoundAvatar } from "@vector-im/compound-web";
import { type MatrixClient } from "matrix-js-sdk";
import { useClientState } from "./ClientContext";
import { useOptionalMatrixDrivers } from "./driver/MatrixDriverContext";
import { useHostBridge } from "./HostBridge";
export enum Size {
@@ -77,9 +76,6 @@ export const Avatar: FC<Props> = ({
}) => {
const clientState = useClientState();
const hostBridge = useHostBridge();
// Under a call the host's client driver resolves media; the shell outside
// a call still has only the client.
const drivers = useOptionalMatrixDrivers();
const sizePx = useMemo(
() =>
@@ -99,30 +95,16 @@ export const Avatar: FC<Props> = ({
return;
}
let url: Promise<string | null>;
let blob: Promise<Blob>;
if (hostBridge.downloadMedia) {
url = hostBridge
.downloadMedia(src)
.then((blob) => URL.createObjectURL(blob));
} else if (drivers !== null) {
const px = Math.floor(sizePx * window.devicePixelRatio);
url = drivers.clientDriver.thumbnailUrl(
src,
px,
px,
sizePx <= 96 ? "crop" : "scale",
);
blob = hostBridge.downloadMedia(src);
} else if (
clientState?.state === "valid" &&
clientState.authenticated?.client &&
sizePx
) {
url = getAvatarFromServer(
clientState.authenticated.client,
src,
sizePx,
).then((blob) => URL.createObjectURL(blob));
blob = getAvatarFromServer(clientState.authenticated.client, src, sizePx);
} else {
setAvatarUrl(undefined);
return;
@@ -130,14 +112,13 @@ export const Avatar: FC<Props> = ({
let objectUrl: string | undefined;
let stale = false;
url
.then((resolved) => {
if (stale || resolved === null) {
blob
.then((blob) => {
if (stale) {
return;
}
// Only what we created (or the driver created for us) is ours to revoke.
if (resolved.startsWith("blob:")) objectUrl = resolved;
setAvatarUrl(resolved);
objectUrl = URL.createObjectURL(blob);
setAvatarUrl(objectUrl);
})
.catch((ex) => {
if (stale) {
@@ -152,7 +133,7 @@ export const Avatar: FC<Props> = ({
URL.revokeObjectURL(objectUrl);
}
};
}, [clientState, hostBridge, drivers, src, sizePx]);
}, [clientState, hostBridge, src, sizePx]);
return (
<CompoundAvatar
+32 -2
View File
@@ -17,7 +17,8 @@ import {
type JSX,
} from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { type MatrixClient } from "matrix-js-sdk";
import { type ISyncStateData, type SyncState } from "matrix-js-sdk/lib/sync";
import { ClientEvent, type MatrixClient } from "matrix-js-sdk";
import { ErrorPage } from "./FullScreenView";
import { useHostBridge } from "./HostBridge";
@@ -41,6 +42,9 @@ export type ClientState = ValidClientState | ErrorState;
export type ValidClientState = {
state: "valid";
authenticated?: AuthenticatedClient;
// 'Disconnected' rather than 'connected' because it tracks specifically
// whether the client is supposed to be connected but is not
disconnected: boolean;
supportedFeatures: {
reactions: boolean;
};
@@ -272,6 +276,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
}, [initClientState?.client, setAlreadyOpenedErr]),
);
const [isDisconnected, setIsDisconnected] = useState(false);
const [supportsReactions, setSupportsReactions] = useState(false);
const state: ClientState | undefined = useMemo(() => {
@@ -295,6 +300,7 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
state: "valid",
authenticated,
setClient,
disconnected: isDisconnected,
supportedFeatures: {
reactions: supportsReactions,
},
@@ -305,9 +311,17 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
initClientState,
logout,
setClient,
isDisconnected,
supportsReactions,
]);
const onSync = useCallback(
(state: SyncState, _old: SyncState | null, data?: ISyncStateData) => {
setIsDisconnected(clientIsDisconnected(state, data));
},
[],
);
useEffect(() => {
if (!initClientState) {
return;
@@ -319,10 +333,20 @@ export const ClientProvider: FC<Props> = ({ children, client }) => {
if (PosthogAnalytics.hasInstance())
PosthogAnalytics.instance.onLoginStatusChanged();
if (initClientState.client) {
initClientState.client.on(ClientEvent.Sync, onSync);
}
if (!hostBridge.supportsReactions)
logger.warn("The host does not permit reactions");
setSupportsReactions(hostBridge.supportsReactions);
}, [initClientState, hostBridge]);
return (): void => {
if (initClientState.client) {
initClientState.client.removeListener(ClientEvent.Sync, onSync);
}
};
}, [initClientState, onSync, hostBridge]);
if (alreadyOpenedErr) {
return <ErrorPage error={alreadyOpenedErr} />;
@@ -364,3 +388,9 @@ const loadSession = (): Session | undefined => {
return JSON.parse(data);
};
const clientIsDisconnected = (
syncState: SyncState,
syncData?: ISyncStateData,
): boolean =>
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
+18
View File
@@ -0,0 +1,18 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.banner {
position: absolute;
padding: 29px;
background-color: var(--cpd-color-bg-subtle-primary);
vertical-align: middle;
font-size: var(--font-size-body);
text-align: center;
z-index: 1;
top: 76px;
width: calc(100% - 58px);
}
+44
View File
@@ -0,0 +1,44 @@
/*
Copyright 2023, 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import classNames from "classnames";
import { type FC, type HTMLAttributes, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import styles from "./DisconnectedBanner.module.css";
import { type ValidClientState, useClientState } from "./ClientContext";
interface Props extends HTMLAttributes<HTMLElement> {
children?: ReactNode;
className?: string;
}
export const DisconnectedBanner: FC<Props> = ({
children,
className,
...rest
}) => {
const { t } = useTranslation();
const clientState = useClientState();
let shouldShowBanner = false;
if (clientState?.state === "valid") {
const validClientState = clientState as ValidClientState;
shouldShowBanner = validClientState.disconnected;
}
return (
<>
{shouldShowBanner && (
<div className={classNames(styles.banner, className)} {...rest}>
{children}
{t("disconnected_banner")}
</div>
)}
</>
);
};
+18 -7
View File
@@ -23,27 +23,38 @@ import { Avatar, Size } from "./Avatar";
import { EncryptionLock } from "./room/EncryptionLock";
import { useRootSizeMatches } from "./useRootSize";
import { useLeaveToHome } from "./LeaveToHomeContext";
import { DisconnectedBanner } from "./DisconnectedBanner";
interface HeaderProps extends HTMLAttributes<HTMLElement> {
ref?: Ref<HTMLElement>;
children: ReactNode;
className?: string;
/**
* Whether the header should display an informational banner whenever the
* client is disconnected from the homeserver.
* @default true
*/
disconnectedBanner?: boolean;
}
export const Header: FC<HeaderProps> = ({
ref,
children,
className,
disconnectedBanner = true,
...rest
}) => {
return (
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
<>
<header
ref={ref}
className={classNames(styles.header, className)}
{...rest}
>
{children}
</header>
{disconnectedBanner && <DisconnectedBanner />}
</>
);
};
+7 -8
View File
@@ -19,7 +19,6 @@ import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { PosthogAnalytics } from "./PosthogAnalytics";
import {
mediaKeyStatisticsOf,
CallEndedTracker,
CallReconnectingTracker,
type CallReconnectingReason,
@@ -68,7 +67,7 @@ describe("CallEnded", () => {
const tracker = new CallEndedTracker();
const mockSession = createMockRtcSession();
tracker.track("test-call-id", 2, false, mediaKeyStatisticsOf(mockSession));
tracker.track("test-call-id", 2, false, mockSession);
expect(warnSpy).toHaveBeenCalledWith(
"[PosthogEvents] Failed to send posthog callEnded event due to missing startTime",
@@ -82,7 +81,7 @@ describe("CallEnded", () => {
tracker.cacheStartCall(new Date(Date.now() - 60000));
tracker.cacheParticipantCountChanged(5);
tracker.track("test-call-id", 3, true, mediaKeyStatisticsOf(mockSession));
tracker.track("test-call-id", 3, true, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
{
@@ -112,7 +111,7 @@ describe("CallEnded", () => {
tracker.cacheParticipantCountChanged(3);
tracker.cacheParticipantCountChanged(7);
tracker.cacheParticipantCountChanged(2);
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -129,7 +128,7 @@ describe("CallEnded", () => {
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -147,7 +146,7 @@ describe("CallEnded", () => {
});
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
@@ -162,7 +161,7 @@ describe("CallEnded", () => {
const mockSession = createMockRtcSession();
tracker.cacheStartCall(new Date());
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.anything(),
@@ -179,7 +178,7 @@ describe("CallEnded", () => {
tracker.cacheReconnecting("sync");
tracker.cacheReconnecting("livekit");
tracker.cacheReconnecting("membership");
tracker.track("test-call-id", 1, false, mediaKeyStatisticsOf(mockSession));
tracker.track("test-call-id", 1, false, mockSession);
expect(PosthogAnalytics.instance.trackEvent).toHaveBeenCalledWith(
expect.objectContaining({
+10 -32
View File
@@ -15,34 +15,6 @@ import {
RegistrationType,
} from "./PosthogAnalytics";
/** How many media keys went out and came in over a call, for the ended event. */
export interface MediaKeyStatistics {
sent: number;
received: number;
/** Sum of the ages of the received keys, in ms; averaged on send. */
receivedTotalAge: number;
}
/** No key statistics (the Rust crate does not count them yet). */
export const NO_MEDIA_KEY_STATISTICS: MediaKeyStatistics = {
sent: 0,
received: 0,
receivedTotalAge: 0,
};
/** {@link MediaKeyStatistics} from a matrix-js-sdk session. */
export function mediaKeyStatisticsOf(
rtcSession: MatrixRTCSession,
): MediaKeyStatistics {
const { counters, totals } = rtcSession.statistics;
return {
sent: counters.roomEventEncryptionKeysSent,
received: counters.roomEventEncryptionKeysReceived,
// Only meaningful with received keys; a mocked session may not carry it.
receivedTotalAge: totals?.roomEventEncryptionKeysReceivedTotalAge ?? 0,
};
}
interface CallEnded extends IPosthogEvent {
eventName: "CallEnded";
// the callId posthog key is essentially a Matrix roomId
@@ -108,7 +80,7 @@ export class CallEndedTracker {
callId: string,
callParticipantsNow: number,
sendInstantly: boolean,
keys: MediaKeyStatistics,
rtcSession: MatrixRTCSession,
): void {
if (this.cache.startTime) {
PosthogAnalytics.instance.trackEvent<CallEnded>(
@@ -118,10 +90,16 @@ export class CallEndedTracker {
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
roomEventEncryptionKeysSent: keys.sent,
roomEventEncryptionKeysReceived: keys.received,
roomEventEncryptionKeysSent:
rtcSession.statistics.counters.roomEventEncryptionKeysSent,
roomEventEncryptionKeysReceived:
rtcSession.statistics.counters.roomEventEncryptionKeysReceived,
roomEventEncryptionKeysReceivedAverageAge:
keys.received > 0 ? keys.receivedTotalAge / keys.received : 0,
rtcSession.statistics.counters.roomEventEncryptionKeysReceived > 0
? rtcSession.statistics.totals
.roomEventEncryptionKeysReceivedTotalAge /
rtcSession.statistics.counters.roomEventEncryptionKeysReceived
: 0,
callReconnectingCount: this.cache.reconnectingCount,
callReconnectingCountSync: this.cache.reconnectingCountByReason.sync,
callReconnectingCountMembership:
+2 -10
View File
@@ -17,10 +17,7 @@ import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { getBasicCallViewModelEnvironment } from "../utils/test-viewmodel";
import { alice, local, localRtcMember } from "../utils/test-fixtures";
import { type MockRTCSession } from "../utils/test";
import {
jsSdkReactionsTimeline,
ReactionsSenderProvider,
} from "../reactions/useReactionsSender";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { initializeWidget } from "../widget";
initializeWidget();
vi.mock("livekit-client/e2ee-worker?worker");
@@ -38,12 +35,7 @@ function TestComponent({
<TooltipProvider>
<ReactionsSenderProvider
vm={vm}
ownIdentifier={localIdent}
ownMembershipEventId={localRtcMember.eventId}
timeline={jsSdkReactionsTimeline(
rtcSession.room.client,
rtcSession.room.roomId,
)}
rtcSession={rtcSession.asMockedSession()}
>
<ReactionToggleButton
reactionData={{
+20
View File
@@ -12,6 +12,7 @@ import { Link } from "@vector-im/compound-web";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { CallFooter, type FooterSnapshot } from "./CallFooter";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import inCallViewStyles from "../room/InCallView.module.css";
import { useStaticViewModel } from "../state/ViewModel";
import { ReactionsSenderContext } from "../reactions/useReactionsSender";
@@ -82,6 +83,15 @@ const fnArgType = {
const meta = {
component: CallFooterStoryWrapper,
// The footer reads the backgrounds this device keeps from the processor
// provider, the same way the lobby and the call do.
decorators: [
(Story): JSX.Element => (
<ProcessorProvider>
<Story />
</ProcessorProvider>
),
],
argTypes: {
layout: {
control: "radio",
@@ -124,6 +134,8 @@ export const Default: Story = {
toggleScreenSharing: fn(),
toggleBlur: fn(),
videoBlurEnabled: true,
backgroundEffect: "none",
selectBackgroundEffect: fn(),
hangup: fn(),
buttonSize: "lg",
showFooter: true,
@@ -137,10 +149,13 @@ export const Default: Story = {
debugTileLayout: false,
tileStoreGeneration: undefined,
audioOptions: [],
audioOutputOptions: [],
videoOptions: [],
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
},
parameters: {
@@ -158,11 +173,16 @@ export const WithAudioAndVideoOptions: Story = {
{ label: { type: "name", name: "Microphone 1" }, id: "1" },
{ label: { type: "name", name: "Microphone 2" }, id: "2" },
],
audioOutputOptions: [
{ label: { type: "default", name: "Built-in Output" }, id: "default" },
{ label: { type: "name", name: "Headset" }, id: "2" },
],
videoOptions: [
{ label: { type: "name", name: "Camera 1" }, id: "1" },
{ label: { type: "name", name: "Camera 2" }, id: "2" },
],
selectedAudio: "2",
selectedAudioOutput: "default",
selectedVideo: "1",
},
};
+152 -7
View File
@@ -5,8 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, type JSX, type Ref, useMemo } from "react";
import {
type FC,
type JSX,
type Ref,
useCallback,
useMemo,
useState,
} from "react";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import LogoMark from "../icons/LogoMark.svg?react";
import LogoType from "../icons/LogoType.svg?react";
@@ -23,9 +32,25 @@ import {
} from "../button";
import styles from "./CallFooter.module.css";
import {
type BackgroundEffectOption,
MediaMuteAndSwitchButton,
type MenuOptions,
} from "./MediaMuteAndSwitchButton";
import {
parseEffect,
serializeEffect,
shippedBackgrounds,
} from "../livekit/backgroundEffects";
import {
maxAddedBackgrounds,
UnusableImage,
} from "../livekit/backgroundImages";
import {
useAddedBackgrounds,
useBackgroundProcessing,
} from "../livekit/TrackProcessorContext";
import { usesFallbackProcessing } from "../livekit/backgroundProcessing";
import { SelfPreview } from "./SelfPreview";
import { type Behavior } from "../state/Behavior";
import { type ViewModel } from "../state/ViewModel";
import { useBehavior } from "../useBehavior";
@@ -57,6 +82,8 @@ export interface FooterActions {
/** Also controls if the videoMute button is disabled */
toggleVideo: (() => void) | undefined;
toggleBlur: (() => void) | undefined;
/** Undefined where background processing is unavailable. */
selectBackgroundEffect: ((id: string) => void) | undefined;
toggleScreenSharing: (() => void) | undefined;
/** Also controls if the settings button is visible */
openSettings: (() => void) | undefined;
@@ -70,6 +97,8 @@ export interface FooterState {
videoEnabled: boolean;
videoBusy: boolean;
videoBlurEnabled: boolean;
/** The chosen background effect, in its stored form. */
backgroundEffect: string;
showFooter: boolean;
/* This is needed for WindowMode = "flat" */
@@ -99,11 +128,15 @@ export interface FooterState {
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
audioOptions: MenuOptions[];
/** Output devices shown as their own section in the audio menu. */
audioOutputOptions: MenuOptions[];
/** Providing no options `[]` or `undefined` will imply that we dont have a audio fast switcher */
videoOptions: MenuOptions[];
selectedAudio: string | undefined;
selectedAudioOutput: string | undefined;
selectedVideo: string | undefined;
selectAudioButtonOption: ((deviceId: string) => void) | undefined;
selectAudioOutputOption: ((deviceId: string) => void) | undefined;
selectVideoButtonOption: ((option: string) => void) | undefined;
}
@@ -119,6 +152,7 @@ export const CallFooter: FC<FooterProps> = ({
children,
vm,
}) => {
const { t } = useTranslation();
const asOverlay = useBehavior(vm.asOverlay$);
const showFooter = useBehavior(vm.showFooter$);
const hideControls = useBehavior(vm.hideControls$);
@@ -143,9 +177,106 @@ export const CallFooter: FC<FooterProps> = ({
const audioOptions = useBehavior(vm.audioOptions$);
const selectedAudio = useBehavior(vm.selectedAudio$);
const selectAudioButtonOption = useBehavior(vm.selectAudioButtonOption$);
const audioOutputOptions = useBehavior(vm.audioOutputOptions$);
const selectedAudioOutput = useBehavior(vm.selectedAudioOutput$);
const selectAudioOutputOption = useBehavior(vm.selectAudioOutputOption$);
const selectVideoButtonOption = useBehavior(vm.selectVideoButtonOption$);
const toggleBlur = useBehavior(vm.toggleBlur$);
const videoBlurEnabled = useBehavior(vm.videoBlurEnabled$);
const backgroundEffect = useBehavior(vm.backgroundEffect$);
const selectBackgroundEffect = useBehavior(vm.selectBackgroundEffect$);
const { added, addBackground, removeBackground } = useAddedBackgrounds();
const { settling, cameraTrack } = useBackgroundProcessing();
// Said, not decided. Where only the slow path exists the effect still works,
// it costs frames — and the user is the only one who knows whether they would
// rather show the room they are sitting in. It stays put while the pipeline
// is being built: that wait is shown on the tile that was pressed, where a
// message that came and went would only flash once the assets are cached.
const backgroundEffectNotice = useMemo(
() =>
usesFallbackProcessing()
? t("background_effects.slow_in_this_browser")
: undefined,
[t],
);
const [backgroundEffectError, setBackgroundEffectError] = useState<
string | undefined
>(undefined);
// Spelled out rather than built from the reason, so the extractor can find
// every string it has to translate.
const whyRefused = useCallback(
(e: unknown): string => {
if (e instanceof UnusableImage)
switch (e.reason) {
case "not-an-image":
return t("error.background_not_an_image");
case "animated":
return t("error.background_animated");
case "undecodable":
return t("error.background_undecodable");
}
return t("error.background_not_kept");
},
[t],
);
const onRemoveBackgroundEffect = useCallback(
(id: string): void => {
const effect = parseEffect(id);
if (effect.kind !== "added") return;
removeBackground(effect.id).catch((e) =>
logger.warn("Could not remove that background", e),
);
},
[removeBackground],
);
const onAddBackgroundImage = useCallback(
(file: File): void => {
setBackgroundEffectError(undefined);
// Added, not put on — before joining and during a call alike. It joins
// the backgrounds on offer and is in force once the user chooses it:
// choosing a file and choosing to wear it are two decisions, and one
// rule for both places is one less thing to know.
addBackground(file).catch((e) => {
setBackgroundEffectError(whyRefused(e));
logger.warn(
e instanceof UnusableImage
? `Cannot use that file as a background: ${e.reason}`
: "Could not keep that background",
e,
);
});
},
[addBackground, whyRefused],
);
// The catalogue is named here rather than in the view model: the names are
// for reading, and a view model has no business holding translated text.
const backgroundEffects = useMemo(
(): BackgroundEffectOption[] => [
{ id: "none", kind: "none", label: t("action.background_effect_none") },
{ id: "blur", kind: "blur", label: t("action.background_effect_blur") },
...shippedBackgrounds.map((background, i) => ({
id: serializeEffect({ kind: "shipped", id: background.id }),
kind: "image" as const,
// Numbered rather than named: the images are stand-ins, and naming
// them here would invent names the design has not given them.
label: t("action.background_effect_numbered", { n: i + 1 }),
imageUrl: background.imagePath,
})),
...added.map((background, i) => ({
id: serializeEffect({ kind: "added", id: background.id }),
kind: "image" as const,
removable: true,
label: t("action.background_effect_numbered", {
n: shippedBackgrounds.length + i + 1,
}),
imageUrl: background.url,
})),
],
[t, added],
);
const buttonSize = useBehavior(vm.buttonSize$);
const showLogo = useBehavior(vm.showLogo$);
@@ -168,7 +299,6 @@ export const CallFooter: FC<FooterProps> = ({
if ((audioOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Mic Source"}
key="audio"
iconsAndLabels="audio"
enabled={audioEnabled ?? false}
@@ -178,6 +308,9 @@ export const CallFooter: FC<FooterProps> = ({
options={audioOptions}
selectedOption={selectedAudio}
onSelect={selectAudioButtonOption}
outputOptions={audioOutputOptions}
selectedOutputOption={selectedAudioOutput}
onSelectOutput={selectAudioOutputOption}
/>,
);
} else {
@@ -197,7 +330,6 @@ export const CallFooter: FC<FooterProps> = ({
if ((videoOptions?.length ?? 0) > 0) {
buttons.push(
<MediaMuteAndSwitchButton
title={"Camera Source"}
key="video"
iconsAndLabels="video"
enabled={videoEnabled ?? false}
@@ -206,8 +338,21 @@ export const CallFooter: FC<FooterProps> = ({
options={videoOptions}
selectedOption={selectedVideo}
onSelect={selectVideoButtonOption}
videoBlurToggleClick={toggleBlur}
videoBlurEnabled={videoBlurEnabled}
backgroundEffects={backgroundEffects}
selectedBackgroundEffect={backgroundEffect}
onSelectBackgroundEffect={selectBackgroundEffect}
// Withheld once the device keeps as many as it will, which is what
// renders the add tile unavailable rather than letting it fail.
onAddBackgroundImage={
selectBackgroundEffect && added.length < maxAddedBackgrounds
? onAddBackgroundImage
: undefined
}
onRemoveBackgroundEffect={onRemoveBackgroundEffect}
backgroundEffectError={backgroundEffectError}
backgroundEffectNotice={backgroundEffectNotice}
backgroundEffectSettling={settling}
selfPreview={<SelfPreview track={cameraTrack ?? null} />}
/>,
);
} else {
+149 -4
View File
@@ -14,7 +14,10 @@ import type { CallViewModel } from "../state/CallViewModel/CallViewModel";
import type { Alignment, Layout } from "../state/layout-types";
import type { SpotlightTileViewModel } from "../state/TileViewModel";
import type { DeviceLabel } from "../state/MediaDevices";
import { createCallFooterViewModel } from "./CallFooterViewModel";
import {
createCallFooterViewModel,
createLobbyFooterViewModel,
} from "./CallFooterViewModel";
import { HeaderStyle } from "../UrlParams";
const platformMock = vi.hoisted(() => vi.fn(() => "desktop"));
@@ -24,10 +27,17 @@ vi.mock("../Platform", () => ({
},
}));
// Prevent supportsBackgroundProcessors from throwing in jsdom – it is not
// exercised by these tests (only used in `videoToggles`, not `videoOptions`).
// The SDK's own check needs WebGL and canvas APIs jsdom does not have. The
// tests below drive it directly, because what they are about is the answer the
// app gives on top of it.
const sdkSupportMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("@livekit/track-processors", () => ({
supportsBackgroundProcessors: (): boolean => false,
supportsBackgroundProcessors: (): boolean => sdkSupportMock(),
}));
const outputSelectionMock = vi.hoisted(() => vi.fn(() => true));
vi.mock("livekit-client", () => ({
supportsAudioOutputSelection: (): boolean => outputSelectionMock(),
}));
/**
@@ -96,6 +106,71 @@ const twoMicsAndOneCamMediaDevices = mockMediaDevices({
});
describe("createCallFooterViewModel", () => {
describe("selectAudioOutputOption", () => {
function buildFooterVm(): ReturnType<typeof createCallFooterViewModel> {
platformMock.mockReturnValue("desktop");
return createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
}
it("is withheld where the platform cannot route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(false);
// Undefined is what renders the speaker section disabled.
expect(buildFooterVm().selectAudioOutputOption$.value).toBeUndefined();
});
it("is offered where the platform can route audio to a chosen device", () => {
outputSelectionMock.mockReturnValue(true);
expect(buildFooterVm().selectAudioOutputOption$.value).toBeDefined();
});
});
describe("audioOutputOptions", () => {
it("is an empty list, not absent, where the platform enumerates no outputs", () => {
platformMock.mockReturnValue("desktop");
outputSelectionMock.mockReturnValue(true);
const vm = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
mockMediaDevices({
audioInput: {
available$: constant(
new Map<string, DeviceLabel>([
["mic1", { type: "name", name: "Microphone 1" }],
]),
),
selected$: constant(undefined),
select: vi.fn(),
},
// Safari enumerates no output devices whatsoever. Reproduced by the
// condition rather than by the browser, so it is checked on the
// Linux CI runners that have no Safari to check it with.
audioOutput: {
available$: constant(new Map<string, DeviceLabel>()),
selected$: constant(undefined),
select: vi.fn(),
},
}),
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
// Empty rather than undefined: undefined means this menu has no notion
// of outputs at all, as the camera menu has none, and hides the section.
// Empty means there are none to list, and the menu still shows the
// section with a default in it, disabled.
expect(vm.audioOutputOptions$.value).toEqual([]);
});
});
describe("audioOptions and videoOptions", () => {
function checkEmptyFor(platform: string, layout: Layout): void {
platformMock.mockReturnValue(platform);
@@ -158,4 +233,74 @@ describe("createCallFooterViewModel", () => {
]);
});
});
describe("background effects", () => {
// The lobby, deliberately: in a call the whole switcher is already
// withheld on a phone, so a check there passes whatever the verdict says.
// The lobby keeps its switcher, which is where a phone browser was offered
// every effect, given none of them, and told nothing.
function lobbyFor(
platform: string,
): ReturnType<typeof createLobbyFooterViewModel> {
platformMock.mockReturnValue(platform);
return createLobbyFooterViewModel(
testScope(),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* openSettings */ undefined,
/* hangup */ undefined,
/* showLogo */ false,
);
}
// A phone is offered them, and that is the point of asking the browser
// rather than the platform: measured, a phone on the fast path held 99% of
// its frame rate while a desktop on the slow one held 73%, so refusing by
// platform refused the better device and allowed the worse.
it("offers them on a phone whose browser can run them", () => {
sdkSupportMock.mockReturnValue(true);
const vm = lobbyFor("ios");
expect(vm.selectBackgroundEffect$.value).toBeDefined();
expect(vm.toggleBlur$.value).toBeDefined();
});
it("offers them where the pipeline will honour them", () => {
sdkSupportMock.mockReturnValue(true);
const vm = lobbyFor("desktop");
expect(vm.selectBackgroundEffect$.value).toBeDefined();
expect(vm.toggleBlur$.value).toBeDefined();
});
it("offers nothing where the browser itself cannot run them", () => {
sdkSupportMock.mockReturnValue(false);
const vm = lobbyFor("desktop");
expect(vm.selectBackgroundEffect$.value).toBeUndefined();
expect(vm.toggleBlur$.value).toBeUndefined();
});
it("availability is the same before and during a call", () => {
for (const supported of [true, false]) {
sdkSupportMock.mockReturnValue(supported);
platformMock.mockReturnValue("desktop");
const inCall = createCallFooterViewModel(
testScope(),
buildMinimalCallViewModel(gridLayout),
mockMuteStates(),
twoMicsAndOneCamMediaDevices,
/* reactionIdentifier */ undefined,
{ showControls: true, header: HeaderStyle.Standard },
);
const lobby = lobbyFor("desktop");
const offeredInCall =
inCall.selectBackgroundEffect$.value !== undefined;
const offeredInLobby =
lobby.selectBackgroundEffect$.value !== undefined;
expect(offeredInLobby).toBe(offeredInCall);
expect(offeredInLobby).toBe(supported);
}
});
});
});
+55 -32
View File
@@ -5,14 +5,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { combineLatest, map, switchMap } from "rxjs";
import { supportsBackgroundProcessors } from "@livekit/track-processors";
import { combineLatest, map, type Observable, switchMap } from "rxjs";
import { supportsAudioOutputSelection } from "livekit-client";
import { supportsBackgroundProcessors } from "../livekit/backgroundProcessing";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { type MenuOptions } from "./MediaMuteAndSwitchButton";
import { type MediaDevices } from "../state/MediaDevices";
import {
backgroundBlur as backgroundBlurSettings,
backgroundEffect as backgroundEffectSetting,
debugTileLayout as debugTileLayoutSetting,
} from "../settings/settings";
import { type Behavior, constant } from "../state/Behavior";
@@ -67,49 +70,52 @@ function buildDeviceBehaviors(
| "audioOptions$"
| "selectedAudio$"
| "selectAudioButtonOption$"
| "audioOutputOptions$"
| "selectedAudioOutput$"
| "selectAudioOutputOption$"
| "videoOptions$"
| "selectedVideo$"
| "selectVideoButtonOption$"
| "toggleBlur$"
| "videoBlurEnabled$"
| "backgroundEffect$"
| "selectBackgroundEffect$"
> {
return {
audioOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.audioInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
const options$ = (
available$: Behavior<Map<string, MenuOptions["label"]>>,
): Observable<MenuOptions[]> =>
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({ id, label })),
),
),
),
),
),
);
return {
audioOptions$: scope.behavior(options$(mediaDevices.audioInput.available$)),
selectedAudio$: scope.behavior(
mediaDevices.audioInput.selected$.pipe(map((s) => s?.id)),
),
selectAudioButtonOption$: constant(mediaDevices.audioInput.select),
videoOptions$: scope.behavior(
disableSwitcher$.pipe(
switchMap((disable) =>
disable
? constant([] as MenuOptions[])
: mediaDevices.videoInput.available$.pipe(
map((available) =>
[...available.entries()].map(([id, label]) => ({
id,
label,
})),
),
),
),
),
audioOutputOptions$: scope.behavior(
options$(mediaDevices.audioOutput.available$),
),
selectedAudioOutput$: scope.behavior(
mediaDevices.audioOutput.selected$.pipe(map((s) => s?.id)),
),
// Safari and most Firefox builds cannot route audio to a chosen device at
// all. Withholding the callback is what renders the section disabled.
selectAudioOutputOption$: constant(
supportsAudioOutputSelection()
? mediaDevices.audioOutput.select
: undefined,
),
videoOptions$: scope.behavior(options$(mediaDevices.videoInput.available$)),
selectedVideo$: scope.behavior(
mediaDevices.videoInput.selected$.pipe(map((s) => s?.id)),
),
@@ -126,6 +132,20 @@ function buildDeviceBehaviors(
),
),
videoBlurEnabled$: backgroundBlurSettings.value$,
backgroundEffect$: backgroundEffectSetting.value$,
// Withholding the callback is what renders the section disabled, the same
// way the speaker section is disabled where no output can be chosen.
selectBackgroundEffect$: scope.behavior(
disableSwitcher$.pipe(
map((switcherDisabled) =>
!switcherDisabled && supportsBackgroundProcessors()
? (id: string): void => {
backgroundEffectSetting.setValue(id);
}
: undefined,
),
),
),
};
}
@@ -263,10 +283,13 @@ export function createLobbyFooterViewModel(
reactionData: undefined,
tileStoreGeneration: undefined,
audioOptions: undefined,
audioOutputOptions: undefined,
videoOptions: undefined,
selectedAudio: undefined,
selectedAudioOutput: undefined,
selectedVideo: undefined,
selectAudioButtonOption: undefined,
selectAudioOutputOption: undefined,
selectVideoButtonOption: undefined,
}),
...buildMuteBehaviors(scope, muteStates),
@@ -0,0 +1,88 @@
{/**
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
**/}
{/**
A custom doc page, replacing the default autodocs for this component, so that
the background effects states can be reviewed together rather than hunted for
in the sidebar. Same filename as the component, which is how Storybook picks
it up.
**/}
import {
Meta,
Primary,
Controls,
Stories,
Title,
Subtitle,
Canvas,
} from "@storybook/addon-docs/blocks";
import * as MenuStories from "./MediaMuteAndSwitchButton.stories";
<Meta of={MenuStories} />
<Title> Device menu </Title>
The menu behind the chevron on the microphone and camera buttons: the devices
to choose from, and — on the camera — the background effects.
<Subtitle> Background effects: the four states worth reviewing </Subtitle>
**Offered.** The ordinary case. No effect, blur, the images we ship, the ones
the user added, and a tile for adding another.
<Canvas of={MenuStories.BackgroundEffects} />
**Offered, with what it costs.** Browsers without the fast path — Safari and
Firefox, on every platform — draw each frame through a canvas instead.
Measured across four of them that costs between a sixth and a quarter of the
frame rate, on a phone and on a desktop alike.
It is said rather than enforced. The cost is smoothness; the reason to pay it
is privacy; and only the person in the room knows which they would rather
have. Everything stays choosable.
Drawn quietly on purpose, to the design: the subtle ground, an outline mark and
the text at its ordinary weight, held the same sixteen pixels from the menu's
sides as the grid above it. Not an alert — an alert is for something that just
happened, and this was true before the menu opened and will be true after it
closes.
<Canvas of={MenuStories.BackgroundEffectsSlowInThisBrowser} />
**Being set up.** Only on the first effect of a session. The tile that was
pressed spins where its tick will go, until a frame has actually come out of
the pipeline — about three seconds of building, and on Safari another twelve to
fifteen while the page holds still. The spinner turns by rotating, which a
browser composites, so it keeps moving through a pause that has stopped
everything else: the difference between looking busy and looking crashed.
On the tile rather than in a message, because once the assets are cached the
wait is a fraction of a second, and a message that appears and vanishes that
fast is noise. A spinner that brief just reads as a control responding. The
notice about what the browser costs stays put throughout — it is true before,
during and after.
This story plays the sequence rather than posing it, shortened to a second and
a half so it is watchable. Later effects skip it entirely: switching a built
pipeline costs nothing.
<Canvas of={MenuStories.BackgroundEffectsSettling} />
**Not possible at all.** Where the browser cannot run background processing on
any path, the tiles are shown and disabled — except no effect, which needs no
processing and stays choosable, so there is always a way back to plain video.
<Canvas of={MenuStories.BackgroundEffectsUnavailable} />
<Subtitle> Mobile layouts </Subtitle>
This component is reactive. To check the mobile layout, open the story from the
sidebar: the summaries on this page do not render it.
<Primary />
<Controls of={MenuStories.Default} />
<Stories />
@@ -35,3 +35,555 @@ Please see LICENSE in the repository root for full details.
transform: rotate(360deg);
}
}
.menu {
display: flex;
flex-direction: column;
}
/* Only the device lists scroll; the level meter stays put beneath them.
The bound comes from the measured height of the call area, set by the
component: the menu is portalled outside the root, so neither a container
query nor a viewport unit describes the space it is allowed to fill. */
.deviceList {
/* Held one border width clear of the frame on each side. The menu draws its
frame as an outline just inside its own edge, and once this list has
enough in it to scroll, Safari paints it — rows and all — above that
outline, so the frame vanished beside every device; measured on the
microphone menu with many devices, 384 pixels of it. Everything the list
holds is inside this, so nothing in it needs to keep clear on its own. */
margin-inline: var(--cpd-border-width-1);
/* The camera menu's width, set by the component, less that clearance so the
menu still comes to it. Without the variable the calc is invalid and the
width falls back to auto, which is what every other menu wants. */
inline-size: calc(
var(--device-list-inline-size) - 2 * var(--cpd-border-width-1)
);
overflow-y: auto;
min-block-size: 0;
max-block-size: var(--device-list-max-height);
/* The heading stands over the head of this list and the level meter over its
foot. Scrolling a row flush to either edge would put it underneath one of
them, which is how a row reached by the keyboard ends up half-readable;
this keeps both their heights clear for anything the browser scrolls to.
Set by the component, which is the only thing that can measure them. */
scroll-padding-block: var(--device-list-scroll-padding-start, 0)
var(--device-list-scroll-padding-end, 0);
}
/* Each section is headed by its own rule, which Compound draws under a menu
heading already. The heading spans the whole menu — the menu has no padding
of its own — so that rule runs edge to edge, stopping only where the frame
is, rather than inset as a separator between the sections would be.
Compound's spacing around it is the design's too, bar one thing: the design
leaves a section's first device further below the rule than Compound does —
measured off the mock, 27px from the rule to the top of the control, against
the 16px that Compound's 8px margin and the row's own 8px padding give. The
difference goes below the rule, so the rule stays tight under its own text
where Compound put it. */
.menu h3 {
margin-block-end: var(--cpd-space-5x);
}
/* One section is set much further from the one above it than Compound's 8px
heading margin allows: measured off the mock, 41px from the last device's
control to the next heading's text, against the 22px we had. Only between
sections — the first heading keeps Compound's spacing, because the menu's
own padding is already above it. */
.deviceList [role="group"] + [role="group"] h3 {
margin-block-start: var(--cpd-space-7x);
}
/* Each section's heading stays at the top of the scrollport while any of that
section is still in view, so a long list never leaves you wondering which
kind of device you are looking at. It leaves with its own section, because
sticky only holds while the group it belongs to is in view.
Opaque, and held a border width clear of the sides, for the same reason as
the meter below: this is a positioned element, so it paints above the
outline the menu draws its frame with, and would swallow it. */
.sectionHeading {
position: sticky;
inset-block-start: 0;
/* Over what scrolls beneath it. Without a layer of its own it is painted in
order with everything else positioned, and the effect tiles — positioned,
to carry their tick and the remove cross — come after it, so the grid
slid over the heading instead of under it. */
z-index: 1;
background: var(--cpd-color-bg-canvas-default);
/* Clear of the frame's top edge; the list it sits in already keeps it clear
of the sides. */
margin-block-start: var(--cpd-border-width-1);
}
/* The meter belongs to the microphone section: it stays at the bottom of the
scrollport while that section is in view, and leaves with it when the list
is scrolled up to the speakers. The wrapper is deliberately unpositioned —
a positioned one paints above the menu's outline and swallows the frame
along this whole section. Sticky is resolved against the scroll container,
so it does not need one. */
.stickyMeter {
position: sticky;
inset-block-end: 0;
/* Opaque, so the list does not show through it as it scrolls past, and held
clear of the frame's bottom edge. The list it sits in already keeps it
clear of the sides. */
background: var(--cpd-color-bg-canvas-default);
margin-block-end: var(--cpd-border-width-1);
}
/* Radix focuses whatever the pointer is over, so the browser's own ring marks
the item under the mouse. It cannot distinguish the two modalities here, so
it is suppressed and replaced by one that can.
Every kind of item the menu can focus, not only the device rows: the camera
menu's blur toggle is a checkbox item and a child of the menu rather than of
the list, and left out it kept the browser's own ring and followed the
pointer with it. */
.menu [role^="menuitem"]:focus,
.menu [role^="menuitem"]:focus-visible {
outline: none;
}
/* Shown only when the keyboard is what moved the focus. */
.menu[data-focus-modality="keyboard"] [role^="menuitem"]:focus {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: calc(-1 * var(--cpd-border-width-2));
}
/*
* The Background effects grid.
*
* Three across matches the design and keeps a thumbnail readable at a glance;
* with no effect, blur and two shipped images, the five a user may add fill it
* to a whole three-by-three at the limit, where the add tile goes away, and
* four and the add tile fill it one short of it.
*
* minmax(0, 1fr) rather than 1fr: a grid track will not shrink below its
* content's minimum width by default, and a tile carrying a label is wider
* than a third of the menu, so plain 1fr lays out three full-width columns and
* clips two of them.
*/
.effectGrid {
display: grid;
/* Thirds of a menu whose width is set, so the tiles are the size design
draws them — about eighty pixels — whatever the device names are. Zero
rather than the content as the floor, so a long label wraps inside its
tile instead of pushing the grid wider than the menu. */
grid-template-columns: repeat(3, minmax(0, 1fr));
/* Every tile the same height, set by the tallest — the ones carrying a
label. Forcing a ratio instead clips the label as soon as the menu is
narrow enough that the ratio cannot hold it. */
grid-auto-rows: 1fr;
/* Measured against the design, where the gap is about a sixth of a tile's
width. A step less than this read as tight, and left the remove cross on
the corner with little room of its own. */
gap: var(--cpd-space-3x);
/* The same inset a menu heading and a device row carry, so the tiles line
their left edge up with the "B" of the heading above them and with the
radio buttons above that. Matching the surrounding boxes instead puts the
tiles to the left of both, since a heading insets its own text. */
/* The sides carry the full inset to the menu's frame. Above, the grid is
the first control of its section, so it starts where a device row's
control starts under its own rule: the heading's spacing supplies most of
that, and this adds the same step a device row holds above its control.
Below, the menu's own padding is already the inset, so nothing is set. */
/* Sixteen from the menu's edge, as the notice below it is and as design
draws both: the list this sits in is already held a border width in from
the frame, so that much less here. */
padding: var(--cpd-space-2x)
calc(var(--cpd-space-4x) - var(--cpd-border-width-1)) 0;
}
/*
* A menu item lays itself out as a grid of chevron, icon and label in a row.
* A tile centres its glyph with the label beneath instead, so this has to
* outweigh that rule rather than sit beside it.
*
* The tiles are a little wider than tall, as the design draws them, and hold
* that shape whatever the menu measures.
*/
.effectGrid .effectTile {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
/* Glyph and label centre together as one group. Pinning the label to the
floor instead leaves the glyph riding high in the space above it. */
justify-content: center;
/* Measured against the design: the glyph sits a quarter of the tile wide,
with about a seventh of the tile's height between its ink and the label's.
The label's own leading is part of that distance, so the step here is
smaller than the ink gap it produces. */
gap: var(--cpd-space-1x);
block-size: auto;
min-block-size: 0;
min-inline-size: 0;
/* More room above and below than at the sides: the tile is wider than tall,
and the padding is what carries that proportion now the gap is set by the
design's spacing rather than by filling the box. */
padding: var(--cpd-space-3x) var(--cpd-space-2x);
overflow: hidden;
border: 2px solid var(--cpd-color-border-interactive-secondary);
border-radius: var(--cpd-space-2x);
}
/* The selection is drawn here and announced by the item's aria-checked, so it
never rests on colour alone. Matched to the specificity of the base tile
rule above, which also sets a border-color and would otherwise win.
Colour alone, at the width every tile already carries: thickening it with an
inset shadow drew it inside the padding box, where an image tile's picture
sits, so the picture covered it and an added background wore a thinner frame
than no effect did. */
.effectGrid .effectTileSelected {
border-color: var(--cpd-color-border-accent);
}
.effectGrid .effectTile > * {
/* A menu item spaces its icon and label for a row, with the gap carried as a
right margin on each. Stacked in a tile that margin is dead space on one
side, which shifts the glyph and the label off the tile's centre. */
margin: 0;
min-inline-size: 0;
max-inline-size: 100%;
text-align: center;
overflow-wrap: anywhere;
}
.effectSwatch {
flex: 0 0 auto;
inline-size: 100%;
display: flex;
align-items: center;
justify-content: center;
color: var(--cpd-color-icon-secondary);
}
/* An image tile is the picture: it fills the tile rather than sitting in a box
inside one, which is what the design draws and what makes a background
recognisable at this size. */
.effectThumb {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* Blur has no icon in the design system, and a hatch reads as "softened" in a
way a glyph does not. */
.effectBlurGlyph {
/* Smaller than the glyph beside it, and than the design's own hatch measures.
The design's is an icon whose strokes shorten towards the corners, so it
leaves them empty and reads lighter; a uniform hatch fills its box and
reads heavier at the same size. */
inline-size: 18px;
block-size: 18px;
border-radius: var(--cpd-space-1x);
background-image: repeating-linear-gradient(
135deg,
var(--cpd-color-icon-secondary) 0 1.2px,
transparent 1.2px 3.15px
);
}
/* The tick marking the tile in force, standing where the glyph would.
Positioned, so it paints above the image, which is positioned itself and
would otherwise cover anything left in normal flow. */
.effectCheck {
position: relative;
color: var(--cpd-color-icon-accent-primary);
}
/* On a picture it needs its own ground: a tick alone disappears into whatever
the image happens to be behind it. */
.effectCheckOnImage {
border-radius: 50%;
background: var(--cpd-color-bg-canvas-default);
box-shadow: 0 0 0 var(--cpd-border-width-2) var(--cpd-color-bg-canvas-default);
}
/* A menu item's label is sized for a row; in a tile the design sets it a step
smaller, against the glyph above it. */
.effectLabel {
font: var(--cpd-font-body-sm-medium);
/* A menu item labels itself in the secondary colour, which reads as muted
under a glyph the design draws at full strength. */
color: var(--cpd-color-text-primary);
}
/* Named for assistive technology, not drawn: an image labels itself, and the
design gives the add tile a plus alone. */
.effectLabelUnseen {
position: absolute;
inline-size: 1px;
block-size: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
/*
* Unavailable. Opacity alone is not enough on an image tile: a washed-out
* photograph still reads as a photograph you could pick. Draining the colour
* as well says plainly that there is nothing to choose here.
*/
.effectGrid .effectTile[aria-disabled="true"] {
opacity: 0.55;
}
.effectGrid .effectTile[aria-disabled="true"] .effectThumb {
filter: grayscale(1);
}
/*
* The cross that removes one of the user's own backgrounds.
*
* Shown on hover, and whenever the tile has the keyboard's attention, so the
* two ways of reaching it look the same. It carries its own ground for the
* same reason the tick does: it sits on a picture nobody chose.
*/
.effectRemove {
position: absolute;
/* On the tile's top-right corner, where Zoom and Teams both put it, and
clear of the middle: centred, it sat inside the target for choosing the
tile, so the one destructive action lived where the ordinary one does. */
inset-block-start: 0;
inset-inline-end: 0;
/* It overhangs the corner by about a fifth of itself, as the design draws
it: enough to read as sitting on the tile rather than in it, little
enough that it never reaches the tile beside it. */
translate: 20% -20%;
display: none;
align-items: center;
justify-content: center;
inline-size: 28px;
block-size: 28px;
border-radius: 50%;
background: var(--cpd-color-bg-canvas-default);
/* Red at rest, not only under the pointer. The design says what the button
does before you are on it, and the ground carries the hover instead. */
color: var(--cpd-color-icon-critical-primary);
/* Raised off the tile rather than outlined against it — Compound's own value
for a small raised surface, there being no shadow token. In a dark theme
it falls on a near-black ground and reads as nothing, which is how the
design draws it there too. */
box-shadow: 0 1.2px 2.4px 0 rgb(0 0 0 / 15%);
cursor: pointer;
}
/* Under the pointer the ground turns critical and the shadow gives way to a
ring, so the button reads as pressable and as destructive at once. */
.effectRemove:hover {
background: var(--cpd-color-bg-critical-subtle-hovered);
box-shadow: 0 0 0 var(--cpd-border-width-1)
var(--cpd-color-border-critical-subtle);
}
.effectGrid .effectTileWrap:hover .effectRemove,
.effectGrid .effectTileWrap:focus-within .effectRemove {
display: flex;
}
/* Holds a tile that can be removed, so the cross can sit over it without
being inside the menu item. */
.effectTileWrap {
position: relative;
display: flex;
min-inline-size: 0;
}
.effectGrid .effectTileWrap > .effectTile {
flex: 1;
min-inline-size: 0;
}
/* Sits under the tiles, inside the same margin they keep from the frame, and
with the same distance above it as below: the menu's own bottom padding
supplies the lower half, so only the upper half is set here. Without it the
message sits flush against the tiles with a gap twice its size underneath. */
.effectError {
/* For the same reason as the notice below: a refusal's title is a sentence,
and would otherwise set the menu's width rather than fit inside it. */
contain: inline-size;
/* A step short of the margin it keeps at the sides: the menu is a column
with a gap of its own, which makes up the difference above. */
padding: var(--cpd-space-3x) var(--cpd-space-4x) 0;
}
/* The spinner stands exactly where the tick will, so the tile does not shift
when one becomes the other. */
.effectBusy {
display: inline-flex;
align-items: center;
justify-content: center;
inline-size: 20px;
block-size: 20px;
}
/*
* What the browser costs, said quietly.
*
* Measured off the design: the subtle secondary ground, held the same sixteen
* pixels from the menu's sides as the grid above it, with the mark and the
* text on one line's baseline and the text at its ordinary weight. Not an
* alert — an alert is for something that just happened, and this was true
* before the menu opened and will be true after it closes.
*/
.effectNotice {
/* Takes the width the menu has rather than lending it one. The menu is as
wide as its widest content, and a sentence asks for its whole length on
one line: without this the notice widened the camera menu from 296 to 340
pixels, past the width the grid and the preview are drawn for. */
contain: inline-size;
display: flex;
align-items: flex-start;
gap: var(--cpd-space-3x);
/* Nothing below: the menu's own bottom padding is already the inset, and
setting one here as well made the gap under the notice twice the gap
beside it. The same reason the grid leaves its own bottom alone. */
margin: var(--cpd-space-2x) var(--cpd-space-4x) 0;
padding: var(--cpd-space-3x);
border-radius: var(--cpd-space-2x);
background: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-text-primary);
font: var(--cpd-font-body-md-regular);
}
.effectNotice > svg {
flex-shrink: 0;
color: var(--cpd-color-icon-secondary);
}
/*
* How the user looks, at the top of the camera menu.
*
* Edge to edge and flush with the top, as the design draws it: it takes the
* menu's own top padding, which is why it pulls itself up over it. Held one
* border width clear of the frame and rounded to match what is left of the
* frame's corner, because a video paints on a layer of its own and would
* otherwise draw over the outline the menu is framed with.
*
* Sixteen by nine across the camera menu's fixed width, less its frame, and
* written as a height rather than a ratio: with a ratio and a cap Safari kept
* the ratio by narrowing the box, and the preview stopped short of the menu's
* right edge. Cropped rather than letterboxed where the call is narrow enough
* to narrow the menu — a face cut at the edge reads as a self-view, a face in
* bars reads as a broken one.
*/
.selfPreview {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-block-end: var(--cpd-space-2x);
block-size: 166px;
overflow: hidden;
border-start-start-radius: calc(
var(--cpd-space-3x) - var(--cpd-border-width-1)
);
border-start-end-radius: calc(
var(--cpd-space-3x) - var(--cpd-border-width-1)
);
background: var(--cpd-color-bg-subtle-secondary);
color: var(--cpd-color-icon-secondary);
}
/* Above the list: it takes the menu's own top padding, which is why it pulls
itself up over it, and it keeps clear of the frame's sides itself, since it
is not inside the list that otherwise does. */
.selfPreviewPinned {
margin-block-start: calc(
-1 * var(--cpd-space-5x) + var(--cpd-border-width-1)
);
margin-inline: var(--cpd-border-width-1);
}
/* Inside the list, where there was no room to pin it. A scrolling container
cuts off whatever is pulled above its top, so it is the list that moves up
over the menu's padding instead, to bring the preview flush with the top.
That puts the list's corners where the frame's are rounded, so they are
rounded too: square, a stuck heading would show past the curve. */
.deviceListLeadsWithPreview {
margin-block-start: calc(
-1 * var(--cpd-space-5x) + var(--cpd-border-width-1)
);
border-start-start-radius: calc(
var(--cpd-space-3x) - var(--cpd-border-width-1)
);
border-start-end-radius: calc(
var(--cpd-space-3x) - var(--cpd-border-width-1)
);
}
/* Laid over the box rather than in it. In flow, a sixteen-by-nine picture
166 pixels tall asks for 295 of width, and the menu, sized to its widest
content, grew by the pixel that asked for. Out of flow it only fills what
the menu's width already gives it. */
.selfPreview > video,
.selfPreview > img {
position: absolute;
inset: 0;
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
/* The camera faces the user, so the picture is what a mirror would show —
the same as the pre-join preview, or the two would disagree. */
.selfPreview > .mirrored {
transform: scaleX(-1);
}
/*
* A fade at each edge of the list while there is more that way.
*
* In place of the scrollbar, which the platform fades out after a moment: with
* a trackpad on a Mac there was otherwise nothing to say that the list scrolls,
* and it looked as though the backgrounds simply stopped. Sticky, so each holds
* its edge of what is in view, and taking no room: each gives back its own
* height in a margin. Over the content, so the rows and tiles fade into it
* rather than past it, since they are opaque and a background would not show.
*
* The top one stands below whichever heading holds the top of the list, which
* is where the content is coming out from under.
*/
.scrollEdge {
position: sticky;
z-index: 1;
block-size: var(--cpd-space-6x);
pointer-events: none;
opacity: 0;
}
@media (prefers-reduced-motion: no-preference) {
.scrollEdge {
transition: opacity 150ms ease-out;
}
}
.scrollEdgeShown {
opacity: 1;
}
.scrollEdgeTop {
inset-block-start: var(--device-list-stuck-heading-height, 0);
margin-block-end: calc(-1 * var(--cpd-space-6x));
background: linear-gradient(
to bottom,
var(--cpd-color-bg-canvas-default),
transparent
);
}
.scrollEdgeBottom {
inset-block-end: 0;
margin-block-start: calc(-1 * var(--cpd-space-6x));
background: linear-gradient(
to top,
var(--cpd-color-bg-canvas-default),
transparent
);
}
File diff suppressed because it is too large Load Diff
+383 -11
View File
@@ -6,7 +6,14 @@ Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test, vi } from "vitest";
import { act, render, screen, type RenderResult } from "@testing-library/react";
import { axe } from "vitest-axe";
import {
act,
render,
screen,
within,
type RenderResult,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { type JSX, useState, type ReactNode } from "react";
import { TooltipProvider } from "@vector-im/compound-web";
@@ -327,7 +334,7 @@ describe("MediaMuteAndSwitchButton", () => {
expect(onVideoBlurToggle).toHaveBeenCalled();
});
test("renders check icon to mark the selected menu item", async () => {
test("marks the selected menu item as checked", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
@@ -342,19 +349,384 @@ describe("MediaMuteAndSwitchButton", () => {
/>,
);
// open menu
await user.click(getByRole("button", { name: "Microphone" }));
// The selected item (mic2) renders both an IconOptions SVG and a CheckIcon SVG
const mic1Item = screen.getByRole("menuitemradio", {
name: "Microphone 2",
});
expect(mic1Item.querySelectorAll("svg").length).toBe(2);
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: false });
});
// The unselected item (mic1) only renders its IconOptions SVG
const mic2Item = screen.getByRole("menuitemradio", {
test("disables every device while a selection is settling", async () => {
const user = userEvent.setup();
const { promise, resolve } = Promise.withResolvers<void>();
function Wrapper(): JSX.Element {
const [selectedOption, setSelectedOption] = useState("mic1");
return (
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption={selectedOption}
onSelect={(id) => {
void promise.then(() => setSelectedOption(id));
}}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>
);
}
const { getByRole } = renderComponent(<Wrapper />);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
);
// In flight: nothing else can be picked, in either section, so a second
// request cannot overtake the first.
for (const name of ["Microphone 1", "Speakers", "Headset"]) {
expect(screen.getByRole("menuitemradio", { name })).toHaveAttribute(
"aria-disabled",
"true",
);
}
await act(async () => {
resolve();
await promise;
});
// Settled: choosable again.
expect(
screen.getByRole("menuitemradio", { name: "Microphone 1" }),
).not.toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).not.toHaveAttribute("aria-disabled", "true");
});
test("camera menu uses the same selection pattern and keeps the blur toggle", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="video"
enabled={true}
options={[
{ label: { type: "name", name: "Camera 1" }, id: "cam1" },
{ label: { type: "name", name: "Camera 2" }, id: "cam2" },
]}
selectedOption="cam1"
onSelect={vi.fn()}
videoBlurToggleClick={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Camera" }));
// Same selection pattern as the microphone menu.
screen.getByRole("menuitemradio", { name: "Camera 1", checked: true });
screen.getByRole("menuitemradio", { name: "Camera 2", checked: false });
// And background blur is still reachable from here.
expect(
screen.getByRole("menuitemcheckbox", { name: "Blur background" }),
).toBeInTheDocument();
});
test("marks focus as keyboard-driven only when the keyboard moved it", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const list = screen
.getByRole("menuitemradio", { name: "Microphone 1" })
.closest("[data-focus-modality]");
// The menu focuses whatever the pointer is over, so focus alone says
// nothing about how someone is navigating.
expect(list).toHaveAttribute("data-focus-modality", "pointer");
await user.keyboard("{ArrowDown}");
expect(list).toHaveAttribute("data-focus-modality", "keyboard");
await user.pointer({
target: screen.getByRole("menuitemradio", { name: "Microphone 2" }),
coords: { clientX: 10, clientY: 10 },
});
expect(list).toHaveAttribute("data-focus-modality", "pointer");
});
test("marks the selected device with the accent fill", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic2"
onSelect={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const selected = screen
.getByRole("menuitemradio", { name: "Microphone 2" })
.querySelector("input[type=radio]");
expect(selected).toBeChecked();
// A read-only control is painted muted, which loses the accent fill that
// marks the selection and makes the menu differ from settings.
expect(selected).not.toHaveAttribute("readonly");
});
test("the open menu has no accessibility violations", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// Includes the menu's own structure: wrappers between the menu and its
// items break the relationship the roles describe.
const menu = document.querySelector('[role="menu"]');
expect(await axe(menu as HTMLElement)).toHaveNoViolations();
});
test("puts the speaker section above the microphone section", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
const speakers = screen.getByRole("menuitemradio", { name: "Speakers" });
const microphone = screen.getByRole("menuitemradio", {
name: "Microphone 1",
});
expect(mic2Item.querySelectorAll("svg").length).toBe(1);
expect(
speakers.compareDocumentPosition(microphone) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
test("lists speaker and microphone sections", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{
label: { type: "default", name: "Built-in Output" },
id: "default",
},
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="default"
onSelectOutput={vi.fn()}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
screen.getByRole("menuitemradio", {
name: "Default (Built-in Output)",
checked: true,
});
screen.getByRole("menuitemradio", { name: "Headset", checked: false });
screen.getByRole("menuitemradio", { name: "Microphone 1", checked: true });
screen.getByRole("menuitemradio", { name: "Microphone 2", checked: false });
});
test("calls the output select callback on speaker click", async () => {
const user = userEvent.setup();
const onSelectOutput = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
onSelectOutput={onSelectOutput}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
await user.click(screen.getByRole("menuitemradio", { name: "Headset" }));
expect(onSelectOutput).toHaveBeenCalledWith("spk2");
});
test("shows a single device entry disabled", async () => {
const user = userEvent.setup();
const onSelect = vi.fn();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
]}
selectedOption="mic1"
onSelect={onSelect}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// Shown rather than hidden, so the menu keeps its shape, but not choosable.
const only = screen.getByRole("menuitemradio", { name: "Microphone 1" });
expect(only).toHaveAttribute("aria-disabled", "true");
});
test("shows a default speaker where the platform lists none", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
// Safari enumerates no output devices at all, and offers no way to
// choose one.
outputOptions={[]}
selectedOutputOption={undefined}
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
// A heading with nothing under it says the feature is broken. Audio is
// playing somewhere, so the section names that somewhere and disables it.
const speakers = screen
.getAllByRole("group")
.find((group) => group.getAttribute("aria-label") === "Speaker")!;
const entries = within(speakers).getAllByRole("menuitemradio");
expect(entries).toHaveLength(1);
expect(entries[0]).toHaveAccessibleName("Default");
expect(entries[0]).toHaveAttribute("aria-disabled", "true");
// And marked as the selection: it is where audio is going, so an unchecked
// lone entry would read as nothing being chosen at all.
expect(entries[0]).toHaveAttribute("aria-checked", "true");
expect(
within(entries[0]).getByRole("radio", { hidden: true }),
).toBeChecked();
});
test("shows the speaker section disabled when output selection is unsupported", async () => {
const user = userEvent.setup();
const { getByRole } = renderComponent(
<MediaMuteAndSwitchButton
title="Switcher"
iconsAndLabels="audio"
enabled={true}
options={[
{ label: { type: "name", name: "Microphone 1" }, id: "mic1" },
{ label: { type: "name", name: "Microphone 2" }, id: "mic2" },
]}
selectedOption="mic1"
onSelect={vi.fn()}
outputOptions={[
{ label: { type: "name", name: "Speakers" }, id: "spk1" },
{ label: { type: "name", name: "Headset" }, id: "spk2" },
]}
selectedOutputOption="spk1"
// No callback: nothing can be picked here.
onSelectOutput={undefined}
/>,
);
await user.click(getByRole("button", { name: "Microphone" }));
expect(
screen.getByRole("menuitemradio", { name: "Speakers" }),
).toHaveAttribute("aria-disabled", "true");
expect(
screen.getByRole("menuitemradio", { name: "Headset" }),
).toHaveAttribute("aria-disabled", "true");
// The microphone section is unaffected.
expect(
screen.getByRole("menuitemradio", { name: "Microphone 2" }),
).not.toHaveAttribute("aria-disabled", "true");
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
.meter {
display: flex;
align-items: center;
gap: var(--cpd-space-3x);
padding-block: var(--cpd-space-2x);
/* The icon shares a centre with the radio controls in the device rows above,
which sit one 4x padding in from the menu's edge. Two things pull this row
out of line with them, and both come off the padding: the row is inset by a
border width so that the menu's frame stays visible behind it (see
`.stickyMeter`), and the icon is wider than a radio control, so it has to
start further left for the two to share a centre. Both are
measured in the MeterAlignsWithTheDeviceRows story rather than trusted:
jsdom lays nothing out, so only a real browser can hold this. */
padding-inline-start: calc(
var(--cpd-space-4x) - var(--cpd-border-width-1) - 2px
);
padding-inline-end: calc(var(--cpd-space-4x) * 2 + var(--cpd-space-2x));
}
.icon {
color: var(--cpd-color-icon-secondary);
flex-shrink: 0;
/* Same width as a device row's icon column, so the bars start where the
device names start. */
inline-size: 24px;
}
.segments {
/* A bar and the space beside it are always the same size; what changes with
the width available is how many bars there are. Spreading a fixed number of
bars instead would make the meter a different shape in every place it is
used, and close them up into one solid block wherever the space ran short —
and a meter whose bars touch stops reading as a count, which is what
carries the level for anyone who cannot rely on the colour.
Both sizes are set here and nowhere else: the component reads them back off
the rendered bars to work out how many fit. */
flex: 1;
/* The width decides the count, never the other way round. Containment is
what enforces that: without it the bars are both a floor under the width
and the widest thing in the menu, so the meter would set the menu's width
and the device names — which are what a person is reading — would have to
fit around it. */
contain: inline-size;
min-inline-size: 0;
display: flex;
align-items: center;
/* Half again as wide as a bar, as the design has it: bars set as close as
their own width read as a solid block long before they touch. */
gap: var(--cpd-space-1-5x);
}
.segment {
flex: none;
inline-size: var(--cpd-space-1x);
block-size: var(--cpd-space-4x);
border-radius: var(--cpd-space-1x);
background: var(--cpd-color-bg-subtle-primary);
}
/* Filled segments carry the level by count as well as by colour, so the meter
stays readable without relying on hue. */
.segmentLit {
background: var(--cpd-color-bg-accent-rest);
}
.message {
color: var(--cpd-color-text-secondary);
/* A paragraph brings a margin below it, which in a centred row does not push
the text down but lifts it: the margin box is what gets centred, so the
words end up above the middle and the icon beside them looks low. */
margin-block: 0;
}
@@ -0,0 +1,189 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { expect, within } from "storybook/test";
import { type JSX } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import {
MicrophoneLevelMeter,
type MicrophoneLevelMeterProps,
} from "./MicrophoneLevelMeter";
import styles from "./MicrophoneLevelMeter.module.css";
import { METER_SEGMENTS } from "../state/MicrophoneLevel";
/**
* A width to show the meter at, close to the menu it lives in.
*
* Not a copy of the menu's width, and nothing depends on the two agreeing: a
* bar and the gap beside it are a fixed size now, so this only decides how many
* bars there is room for. Without a width at all the stories would shrink-wrap
* to almost nothing and show a meter two bars wide, which is no use to anyone
* looking at them.
*/
const STORY_WIDTH = 256;
const meta = {
component: MicrophoneLevelMeter,
decorators: [
(Story): JSX.Element => (
<div style={{ inlineSize: STORY_WIDTH }}>
<Story />
</div>
),
],
argTypes: {
state: {
description:
"What the selected microphone can say about itself: a level, or a reason there is none.",
},
},
} satisfies Meta<typeof MicrophoneLevelMeter>;
export default meta;
type Story = StoryObj<typeof meta>;
/**
* A quiet room. Nothing is lit: room hiss below the noise floor must not read
* as "it can hear me".
*/
export const Silent: Story = {
args: { state: { type: "level", level: 0 } },
play: async ({ canvasElement }) => {
await expect(litSegments(canvasElement)).toBe(0);
},
};
export const QuietSpeech: Story = {
args: { state: { type: "level", level: 5 } },
};
export const NormalSpeech: Story = {
args: { state: { type: "level", level: 12 } },
play: async ({ canvasElement }) => {
// Shown at something like the width of the menu, so the meter in a story
// reads like the meter in a call rather than like a handful of bars. The
// design's own mock has sixteen of them at this width; a floor rather than
// a count, because the number follows from the bar and gap sizes and those
// are the design's to change.
await expect(
canvasElement.getElementsByClassName(styles.segment).length,
).toBeGreaterThanOrEqual(15);
},
};
export const LoudSpeech: Story = {
args: { state: { type: "level", level: METER_SEGMENTS } },
};
/**
* The three volumes differ by how many bars are lit, so the level survives
* greyscale and a screen reader as well as it survives colour.
*/
export const VolumesAreDistinguishable: Story = {
args: { state: { type: "level", level: 5 } },
play: async ({ canvasElement, mount }) => {
const lit: number[] = [];
for (const level of [5, 12, METER_SEGMENTS]) {
await mount(<MicrophoneLevelMeter state={{ type: "level", level }} />);
lit.push(litSegments(canvasElement));
await expect(within(canvasElement).getByRole("meter")).toHaveAttribute(
"aria-valuenow",
String(level),
);
}
// Three different counts, rising: the level is carried by how many bars
// are lit, not by their colour alone.
await expect(new Set(lit).size).toBe(lit.length);
await expect(lit).toEqual([...lit].sort((a, b) => a - b));
},
};
/**
* Permission refused. A message with a next action, never a still meter that
* reads as silence.
*/
export const PermissionDenied: Story = {
args: { state: { type: "permission-denied" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("meter")).toBeNull();
await expect(
canvas.getByText(/Allow access in your browser settings/),
).toBeVisible();
},
};
/** No input device at all, told apart from a refusal. */
export const NoDevice: Story = {
args: { state: { type: "no-device" } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.queryByRole("meter")).toBeNull();
await expect(canvas.getByText(/No microphone found/)).toBeVisible();
// The icon sits on the middle of the words, however many lines they run to.
// A paragraph's own margin would centre its margin box instead, leaving the
// text high and the icon looking low beside it.
const middle = (element: Element): number => {
const box = element.getBoundingClientRect();
return box.top + box.height / 2;
};
const icon = canvasElement.getElementsByClassName(styles.icon)[0];
const words = canvasElement.getElementsByClassName(styles.message)[0];
await expect(Math.abs(middle(icon) - middle(words))).toBeLessThanOrEqual(1);
},
};
/**
* The same meter at two widths.
*
* A bar and the space beside it are always the same size; what changes is how
* many bars there are. Spreading a fixed number of bars instead would make the
* meter a different shape in every place it is used, and close the bars up into
* one block wherever the space ran short — and bars that touch cannot be
* counted, which is what carries the level without colour.
*/
export const ShapeStaysTheSameAtAnyWidth: Story = {
args: { state: { type: "level", level: 12 } },
play: async ({ mount, args }) => {
const narrow = await measureAt(mount, args, 180);
const wide = await measureAt(mount, args, 400);
await expect(narrow.bar).toBe(wide.bar);
await expect(narrow.gap).toBe(wide.gap);
await expect(narrow.count).toBeLessThan(wide.count);
// And the bars are still bars, not one run of colour.
await expect(narrow.gap).toBeGreaterThan(0);
},
};
/** How many bars are painted as carrying level, rather than as empty. */
function litSegments(canvasElement: HTMLElement): number {
return canvasElement.getElementsByClassName(styles.segmentLit).length;
}
/** Renders the meter at one width and reports the shape of its bars. */
async function measureAt(
mount: (ui: JSX.Element) => Promise<unknown>,
args: MicrophoneLevelMeterProps,
width: number,
): Promise<{ count: number; bar: number; gap: number }> {
await mount(
<div style={{ inlineSize: width }}>
<MicrophoneLevelMeter {...args} />
</div>,
);
const bars = Array.from(document.body.getElementsByClassName(styles.segment));
const first = bars[0].getBoundingClientRect();
const second = bars[1].getBoundingClientRect();
return {
count: bars.length,
bar: first.width,
gap: second.left - first.right,
};
}
@@ -0,0 +1,40 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import { MicrophoneLevelMeter } from "./MicrophoneLevelMeter";
import { METER_SEGMENTS } from "../state/MicrophoneLevel";
describe("MicrophoneLevelMeter", () => {
test("announces the level rather than relying on hue", () => {
render(<MicrophoneLevelMeter state={{ type: "level", level: 6 }} />);
const meter = screen.getByRole("meter", { name: "Microphone level" });
expect(meter).toHaveAttribute("aria-valuenow", "6");
expect(meter).toHaveAttribute("aria-valuemax", String(METER_SEGMENTS));
expect(meter).toHaveAttribute("aria-valuetext", `6 of ${METER_SEGMENTS}`);
});
test("shows distinct messages for denied permission and no input device", () => {
const denied = render(
<MicrophoneLevelMeter state={{ type: "permission-denied" }} />,
);
// A next action, not a flat meter that reads as silence.
expect(
denied.getByText(/Allow access in your browser settings/),
).toBeInTheDocument();
expect(denied.queryByRole("meter")).toBeNull();
const missing = render(
<MicrophoneLevelMeter state={{ type: "no-device" }} />,
);
expect(missing.getByText(/No microphone found/)).toBeInTheDocument();
expect(missing.queryByRole("meter")).toBeNull();
});
});
+120
View File
@@ -0,0 +1,120 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useCallback, useState, type FC, type Ref } from "react";
import { Text } from "@vector-im/compound-web";
import { MicOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import classNames from "classnames";
import { useTranslation } from "react-i18next";
import { distinctUntilChanged, map } from "rxjs";
import styles from "./MicrophoneLevelMeter.module.css";
import { METER_SEGMENTS, type MicrophoneState } from "../state/MicrophoneLevel";
import { observeElementSize$ } from "../utils/elementSize";
export interface MicrophoneLevelMeterProps {
state: MicrophoneState;
className?: string;
/**
* The meter's own element. Its height is what a scroll container has to keep
* clear to stop the meter covering the row it has just scrolled to.
*/
ref?: Ref<HTMLDivElement>;
}
/**
* The live input level of the selected microphone, shown beneath it.
*
* It says whether the microphone is picking anything up, which is not the same
* as whether the user is being heard: it keeps moving while muted, and the mute
* control is what says nothing is transmitted.
*/
export const MicrophoneLevelMeter: FC<MicrophoneLevelMeterProps> = ({
state,
className,
ref,
}) => {
const { t } = useTranslation();
// How many bars there is room for. The bars never change size, so this is
// what absorbs a change of width. Starts at the full count so that the first
// paint is a meter rather than a single bar, and so that a renderer with no
// layout at all — jsdom — still draws the whole thing.
const [barCount, setBarCount] = useState(METER_SEGMENTS);
const track = useCallback(
(element: HTMLDivElement | null): (() => void) | undefined => {
if (element === null) return;
const subscription = observeElementSize$(element)
.pipe(
map(({ width }) => barsThatFit(element, width)),
distinctUntilChanged(),
)
.subscribe(setBarCount);
return (): void => subscription.unsubscribe();
},
[],
);
if (state.type !== "level")
return (
<div ref={ref} className={classNames(styles.meter, className)}>
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
<Text size="sm" className={styles.message}>
{state.type === "permission-denied"
? t("microphone_level.permission_denied")
: t("microphone_level.no_device")}
</Text>
</div>
);
return (
<div ref={ref} className={classNames(styles.meter, className)}>
<MicOnIcon width={24} height={24} className={styles.icon} aria-hidden />
<div
ref={track}
className={styles.segments}
role="meter"
aria-label={t("microphone_level.label")}
aria-valuemin={0}
aria-valuemax={METER_SEGMENTS}
aria-valuenow={state.level}
aria-valuetext={t("microphone_level.value", {
level: state.level,
max: METER_SEGMENTS,
})}
>
{Array.from({ length: barCount }, (_, i) => (
<span
key={i}
aria-hidden
className={classNames(styles.segment, {
// The level is a share of the scale, not a number of bars: how
// many bars stand for it depends on how many there are.
[styles.segmentLit]:
i < Math.round((state.level / METER_SEGMENTS) * barCount),
})}
/>
))}
</div>
</div>
);
};
/**
* How many bars fit across `width`, at the size the stylesheet draws them.
*
* Measured off a rendered bar rather than told: the size of a bar and of the
* space beside it are a design question, settled in the stylesheet, and reading
* them back is what keeps them from being settled twice.
*/
function barsThatFit(track: HTMLElement, width: number): number {
const gap = Number.parseFloat(getComputedStyle(track).columnGap);
const bar = track.firstElementChild?.getBoundingClientRect().width ?? 0;
// A renderer that lays nothing out tells us nothing; keep the full count.
if (!(bar > 0) || !(gap >= 0)) return METER_SEGMENTS;
return Math.max(1, Math.floor((width + gap) / (bar + gap)));
}
+54
View File
@@ -0,0 +1,54 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useEffect, useRef } from "react";
import { facingModeFromLocalTrack, type LocalVideoTrack } from "livekit-client";
import { VideoCallOffIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import styles from "./MediaMuteAndSwitchButton.module.css";
interface Props {
/** The camera as the pipeline has it, or none while the camera is off. */
track: LocalVideoTrack | null;
}
/**
* The user's own camera, for the camera menu's preview.
*
* Attached to the same track the pipeline is synced to, so it shows whatever
* effect is in force — the processed picture where one has been chosen, and
* the plain one where none ever has — without a capture or a pipeline of its
* own. A second consumer of one track, not a second track.
*/
export const SelfPreview: FC<Props> = ({ track }) => {
const video = useRef<HTMLVideoElement>(null);
useEffect(() => {
const element = video.current;
if (!track || !element) return;
track.attach(element);
return (): void => {
track.detach(element);
};
}, [track]);
// Nothing to show, and saying so: an effect chosen now still applies when
// the camera comes back, so the grid below stays live.
if (!track) return <VideoCallOffIcon width={32} height={32} />;
return (
<video
ref={video}
muted
playsInline
className={
facingModeFromLocalTrack(track).facingMode === "user"
? styles.mirrored
: undefined
}
/>
);
};
@@ -0,0 +1,42 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { afterEach, describe, expect, test } from "vitest";
import { renderHook } from "@testing-library/react";
import { useMicrophoneLevel } from "./useMicrophoneLevel";
import { restoreAudioCapture, stubAudioCapture } from "../utils/test";
// The capture itself, and releasing it, belong to observeMicrophoneState$ and
// are covered in src/state/MicrophoneLevel.test.ts. What is left here is the
// bridging: when the hook watches, and what it reports before it has an answer.
describe("useMicrophoneLevel", () => {
afterEach(restoreAudioCapture);
test("holds no capture while the meter is not shown", () => {
const capture = stubAudioCapture();
renderHook(() => useMicrophoneLevel("mic1", false));
expect(capture.getUserMedia).not.toHaveBeenCalled();
});
test("starts from nothing rather than the previous device's level", () => {
const capture = stubAudioCapture();
const { result, rerender } = renderHook(
({ id }: { id: string }) => useMicrophoneLevel(id, true),
{ initialProps: { id: "mic1" } },
);
capture.grant();
rerender({ id: "mic2" });
// No level carried over: the meter reads the new device or nothing at all.
expect(result.current).toEqual({ type: "level", level: 0 });
});
});
+41
View File
@@ -0,0 +1,41 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useEffect, useState } from "react";
import {
type MicrophoneState,
observeMicrophoneState$,
} from "../state/MicrophoneLevel";
const IDLE: MicrophoneState = { type: "level", level: 0 };
/**
* Reads the live input level of a microphone, while `active`.
*
* A bridge and nothing else: the capture, its lifetime and the maths belong to
* {@link observeMicrophoneState$}. Scoped to `active` so the device is held
* only while whatever shows the meter is on screen, rather than for the length
* of a call.
*/
export function useMicrophoneLevel(
deviceId: string | undefined,
active: boolean,
): MicrophoneState {
const [state, setState] = useState<MicrophoneState>(IDLE);
useEffect(() => {
if (!active) return;
// Idle first, so a new device starts from nothing rather than from the
// level the previous one was reading.
setState(IDLE);
const subscription = observeMicrophoneState$(deviceId).subscribe(setState);
return (): void => subscription.unsubscribe();
}, [deviceId, active]);
return state;
}
+1 -23
View File
@@ -9,11 +9,7 @@ import { describe, expect, it, vi, afterEach } from "vitest";
import { logger } from "matrix-js-sdk/lib/logger";
import { Config, validateConfig } from "./Config";
import {
CallViewModelImplementation,
DEFAULT_CONFIG,
MatrixRTCMode,
} from "./ConfigOptions";
import { DEFAULT_CONFIG, MatrixRTCMode } from "./ConfigOptions";
describe("validateConfig", () => {
afterEach(() => {
@@ -105,21 +101,3 @@ describe("Config.initWith", () => {
expect(Config.get().ssla).toBe("https://second.invalid/ssla");
});
});
describe("validateConfig call_view_model_implementation", () => {
it.each(Object.values(CallViewModelImplementation))(
"keeps a valid call_view_model_implementation value (%s)",
(value) => {
const result = validateConfig({ call_view_model_implementation: value });
expect(result.call_view_model_implementation).toBe(value);
},
);
it("drops an unknown call_view_model_implementation value", () => {
const result = validateConfig({
call_view_model_implementation:
"yes-please" as unknown as CallViewModelImplementation,
});
expect(result.call_view_model_implementation).toBeUndefined();
});
});
+1 -14
View File
@@ -15,14 +15,11 @@ import {
type ResolvedConfigOptions,
} from "./ConfigOptions";
import { isFailure } from "../utils/fetch";
import { CallViewModelImplementation, MatrixRTCMode } from "./ConfigOptions";
import { MatrixRTCMode } from "./ConfigOptions";
const VALID_MATRIX_RTC_MODES: ReadonlySet<string> = new Set(
Object.values(MatrixRTCMode),
);
const VALID_CALL_VIEW_MODEL_IMPLEMENTATIONS: ReadonlySet<string> = new Set(
Object.values(CallViewModelImplementation),
);
export class Config {
private static internalInstance: Config | undefined;
@@ -139,16 +136,6 @@ export function validateConfig(config: ConfigOptions): ConfigOptions {
);
delete config.matrix_rtc_mode;
}
const implementation = config.call_view_model_implementation;
if (
implementation !== undefined &&
!VALID_CALL_VIEW_MODEL_IMPLEMENTATIONS.has(implementation)
) {
logger.warn(
`Ignoring invalid call_view_model_implementation in config.json: ${String(implementation)}`,
);
delete config.call_view_model_implementation;
}
return config;
}
-20
View File
@@ -24,18 +24,6 @@ export enum MatrixRTCMode {
Matrix_2_0 = "matrix_2_0",
}
/**
* Which MatrixRTC implementation carries a call: matrix-js-sdk's
* `MatrixRTCSession`, or the Rust `matrix-rtc` crate through the host's
* drivers. Both are in the build while the crate path is being proven, so a
* broken call can be compared against the other path in the same session;
* the js-sdk path and this choice go away together.
*/
export enum CallViewModelImplementation {
MatrixJsSdk = "matrix-js-sdk",
MatrixRtc = "matrix-rtc",
}
export interface DelayedLeaveTimings {
/**
* The delay (in milliseconds) with which delayed leave events are sent.
@@ -214,14 +202,6 @@ export interface ConfigOptions {
*/
matrix_rtc_mode?: MatrixRTCMode;
/**
* Pins the {@link CallViewModelImplementation} for all clients on this
* deployment, overriding any per-user choice from the Developer Settings.
* If unset, the user's Developer Settings choice (or its default) wins.
* Temporary: goes away with the matrix-js-sdk implementation.
*/
call_view_model_implementation?: CallViewModelImplementation;
/**
* These are low level options that are used to configure the MatrixRTC session.
* Take care when changing these options.
-168
View File
@@ -1,168 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* What Element Call needs from a Matrix client beyond MatrixRTC.
*
* A host implements this once per room next to its {@link RtcMatrixDriver}.
* It covers what the crate deliberately leaves to the application: who is
* in the room and what they are called, sending a reaction, showing an
* avatar, the user's own profile, and what the deployment can do. Homeserver
* connectivity is *not* here: the crate needs it too, so it is part of the
* RTC driver and reaches Element Call as a participation impairment.
*
* Deliberately framework-neutral: plain values, promises and
* `subscribeX(listener)` pairs that return an unsubscribe function, the
* same shape as the crate's sinks. No RxJS crosses this boundary; Element
* Call wraps subscriptions into behaviors itself (see `observe.ts`).
*/
/** Removes the listener it was returned for. */
export type Unsubscribe = () => void;
/**
* What this client and its homeserver can do, as far as Element Call cares —
* not to be confused with widget capabilities, which are what a host *permits*
* rather than what the stack supports.
*/
export interface MatrixClientFeatures {
/** The homeserver accepts sticky events (MSC4354). */
stickyEvents: boolean;
/**
* The events the RTC driver emits carry real decryption metadata. A
* widget client receives events already decrypted by its host and can
* only report the device the content *claims*.
*/
verifiedEventOrigins: boolean;
/** The RTC driver can say whether a sending device is cross-signed (MSC4153). */
crossSigningVerdicts: boolean;
}
export interface RoomInfo {
name: string;
canonicalAlias: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
/** The `m.room.join_rules` value, `null` while unknown. */
joinRule: string | null;
/** Whether the room has an `m.room.encryption` state event. */
encrypted: boolean;
/**
* Whether this user may send the MatrixRTC slot state event
* (`org.matrix.msc4143.rtc.slot`). A call needs an open slot; the client
* that starts a call opens one, which takes the power level for it.
*/
canOpenSlot: boolean;
}
export interface RoomMemberProfile {
userId: string;
displayName: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
membership: "join" | "invite";
}
/**
* Room metadata and the profiles of the people in it.
*
* Call members' names and avatars do not come from here: the crate reads
* `m.room.member` itself and puts them on each membership. This roster is
* for the people who are in the room but not (yet) in the call — the person
* being rung, and how many others there are.
*/
export interface RoomDriver {
getRoomInfo(): RoomInfo;
subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe;
/** Joined and invited members of the room, in or out of the call. */
getRoomMembers(): RoomMemberProfile[];
subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe;
}
/** A decrypted room event, as far as a call needs to know it. */
export interface TimelineEvent {
eventId: string;
type: string;
sender: string;
content: Record<string, unknown>;
originServerTs: number;
/** Set on an `m.room.redaction`. */
redacts?: string;
}
/** Application events in the room: reactions, hand raises, notifications. */
export interface TimelineDriver {
sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }>;
redactEvent(eventId: string): Promise<void>;
/**
* Live room events (not sticky ones — those reach the crate through its
* own sink), decrypted, redactions included, local echoes excluded.
*/
subscribeTimeline(listener: (event: TimelineEvent) => void): Unsubscribe;
/**
* Events already known that relate to `eventId` with the given relation
* type and event type — how a late joiner learns of a raised hand.
*/
getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[];
}
export interface OwnProfile {
displayName: string | null;
/** An `mxc://` URL. */
avatarUrl: string | null;
}
/** The user's own profile. Editing is optional: a host may not allow it. */
export interface ProfileDriver {
getOwnProfile(): OwnProfile;
subscribeOwnProfile(listener: (profile: OwnProfile) => void): Unsubscribe;
setDisplayName?(name: string): Promise<void>;
/** Sets the avatar to `file`, or removes it with `null`. */
setAvatar?(file: Blob | null): Promise<void>;
}
export interface MediaDriver {
/**
* A URL an `<img>` can show for an `mxc://` thumbnail — possibly a `blob:`
* URL the driver fetched with its credentials — or null when the media
* cannot be resolved.
*/
thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null>;
}
/**
* Everything Element Call asks of a Matrix client beyond MatrixRTC, bound to
* one room. One object, sliced into the driver interfaces above the way the crate
* slices its own driver, so a piece of Element Call can ask for no more than
* it needs.
*/
export interface ElementCallMatrixClientDriver
extends RoomDriver, TimelineDriver, ProfileDriver, MediaDriver {
/** Who we publish as. */
readonly userId: string;
readonly deviceId: string;
/** The room this driver is bound to. */
readonly roomId: string;
/** What this client and homeserver support; see {@link MatrixClientFeatures}. */
getMatrixClientFeatures(): Promise<MatrixClientFeatures>;
/** Free-form facts for a rageshake: crypto version, sync state, and so on. */
getDiagnostics?(): Promise<Record<string, string>>;
}
-41
View File
@@ -1,41 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { createContext, use } from "react";
import { type ElementCallMatrixClientDriver } from "./ElementCallMatrixClientDriver";
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
/** The host's two drivers for the room a call is in. */
export interface MatrixDrivers {
rtcDriver: RtcMatrixDriver;
clientDriver: ElementCallMatrixClientDriver;
}
const MatrixDriverContext = createContext<MatrixDrivers | null>(null);
/**
* Makes the drivers available to the call tree. Every host that renders a
* `CallView` provides them: the component from its props, the standalone
* app and the widget from the matrix-js-sdk drivers over their client.
*/
export const MatrixDriverProvider = MatrixDriverContext.Provider;
/** The drivers, or null where no host provided any (a test rendering a leaf). */
export function useOptionalMatrixDrivers(): MatrixDrivers | null {
return use(MatrixDriverContext);
}
/** The drivers; throws where none were provided. */
export function useMatrixDrivers(): MatrixDrivers {
const drivers = useOptionalMatrixDrivers();
if (drivers === null)
throw new Error(
"No Matrix drivers were provided; wrap the call in a MatrixDriverProvider",
);
return drivers;
}
@@ -1,89 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it, vi } from "vitest";
import { MockElementCallMatrixClientDriver } from "./MockElementCallMatrixClientDriver";
import { MOCK_ROOM_ID } from "./MockRtcMatrixDriver";
describe("MockElementCallMatrixClientDriver", () => {
it("notifies room info, member and profile subscribers until unsubscribed", () => {
const driver = new MockElementCallMatrixClientDriver({
roomInfo: { name: "Standup" },
});
expect(driver.getRoomInfo().name).toBe("Standup");
const onInfo = vi.fn();
const off = driver.subscribeRoomInfo(onInfo);
driver.setRoomInfo({ name: "Retro" });
expect(onInfo).toHaveBeenCalledWith(
expect.objectContaining({ name: "Retro" }),
);
off();
driver.setRoomInfo({ name: "Planning" });
expect(onInfo).toHaveBeenCalledTimes(1);
const onMembers = vi.fn();
driver.subscribeRoomMembers(onMembers);
const alice = {
userId: "@alice:example.org",
displayName: "Alice",
avatarUrl: null,
membership: "join" as const,
};
driver.setRoomMembers([alice]);
expect(onMembers).toHaveBeenCalledWith([alice]);
expect(driver.getRoomMembers()).toEqual([alice]);
const onProfile = vi.fn();
driver.subscribeOwnProfile(onProfile);
driver.setOwnProfile({ displayName: "Bob" });
expect(onProfile).toHaveBeenCalledWith({
displayName: "Bob",
avatarUrl: null,
});
});
it("records sent room events, echoes them, and resolves relations", async () => {
const driver = new MockElementCallMatrixClientDriver();
const seen: string[] = [];
driver.subscribeTimeline((e) => seen.push(e.type));
const { eventId } = await driver.sendRoomEvent("m.reaction", {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$membership",
key: "🖐️",
},
});
expect(driver.calls("sendRoomEvent")[0]).toMatchObject({
eventType: "m.reaction",
eventId,
});
expect(seen).toEqual(["m.reaction"]);
expect(
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
).toHaveLength(1);
await driver.redactEvent(eventId);
expect(seen).toEqual(["m.reaction", "m.room.redaction"]);
expect(
driver.getRelatedEvents("$membership", "m.annotation", "m.reaction"),
).toHaveLength(0);
});
it("answers thumbnails for mxc urls only", async () => {
const driver = new MockElementCallMatrixClientDriver();
await expect(
driver.thumbnailUrl("mxc://example.org/abc", 96, 96, "crop"),
).resolves.toContain("abc");
await expect(
driver.thumbnailUrl("https://not-mxc", 96, 96, "crop"),
).resolves.toBeNull();
expect(driver.roomId).toBe(MOCK_ROOM_ID);
await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
stickyEvents: true,
});
});
});
@@ -1,248 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* An {@link ElementCallMatrixClientDriver} whose room lives in memory, for
* tests and stories: room info, members, a timeline and a profile, with
* setters that notify subscribers, and a record of what was sent.
*/
import {
type MatrixClientFeatures,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type Unsubscribe,
} from "./ElementCallMatrixClientDriver";
import {
MOCK_OWN_DEVICE_ID,
MOCK_OWN_USER_ID,
MOCK_ROOM_ID,
} from "./MockRtcMatrixDriver";
export type ClientCall =
| {
kind: "sendRoomEvent";
eventType: string;
content: Record<string, unknown>;
eventId: string;
}
| { kind: "redactEvent"; eventId: string };
export interface MockElementCallMatrixClientDriverOptions {
userId?: string;
deviceId?: string;
roomId?: string;
roomInfo?: Partial<RoomInfo>;
members?: RoomMemberProfile[];
ownProfile?: Partial<OwnProfile>;
features?: Partial<MatrixClientFeatures>;
}
export class MockElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
public readonly outbound: ClientCall[] = [];
private features: MatrixClientFeatures;
private roomInfo: RoomInfo;
private members: RoomMemberProfile[];
private ownProfile: OwnProfile;
private readonly timeline: TimelineEvent[] = [];
private readonly roomInfoListeners = new Set<(info: RoomInfo) => void>();
private readonly memberListeners = new Set<
(members: RoomMemberProfile[]) => void
>();
private readonly timelineListeners = new Set<
(event: TimelineEvent) => void
>();
private readonly profileListeners = new Set<(profile: OwnProfile) => void>();
private nextEventId = 0;
public constructor(options: MockElementCallMatrixClientDriverOptions = {}) {
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.features = {
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
...options.features,
};
this.roomInfo = {
name: "Test room",
canonicalAlias: null,
avatarUrl: null,
joinRule: "public",
encrypted: false,
canOpenSlot: true,
...options.roomInfo,
};
this.members = options.members ?? [];
this.ownProfile = {
displayName: "Me",
avatarUrl: null,
...options.ownProfile,
};
}
public calls<K extends ClientCall["kind"]>(
kind: K,
): Extract<ClientCall, { kind: K }>[] {
return this.outbound.filter((c) => c.kind === kind) as Extract<
ClientCall,
{ kind: K }
>[];
}
// --- room ------------------------------------------------------------------
public getRoomInfo(): RoomInfo {
return this.roomInfo;
}
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
return listen(this.roomInfoListeners, listener);
}
public setRoomInfo(info: Partial<RoomInfo>): void {
this.roomInfo = { ...this.roomInfo, ...info };
for (const l of this.roomInfoListeners) l(this.roomInfo);
}
public getRoomMembers(): RoomMemberProfile[] {
return this.members;
}
public subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe {
return listen(this.memberListeners, listener);
}
public setRoomMembers(members: RoomMemberProfile[]): void {
this.members = members;
for (const l of this.memberListeners) l(members);
}
// --- timeline --------------------------------------------------------------
public async sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }> {
const eventId = `$echo-${this.nextEventId++}`;
this.outbound.push({ kind: "sendRoomEvent", eventType, content, eventId });
// Like sync, the room sees our event once the server has it.
this.emitTimelineEvent({
eventId,
type: eventType,
sender: this.userId,
content,
originServerTs: Date.now(),
});
return Promise.resolve({ eventId });
}
public async redactEvent(eventId: string): Promise<void> {
this.outbound.push({ kind: "redactEvent", eventId });
this.emitTimelineEvent({
eventId: `$echo-${this.nextEventId++}`,
type: "m.room.redaction",
sender: this.userId,
content: { redacts: eventId },
originServerTs: Date.now(),
redacts: eventId,
});
return Promise.resolve();
}
public subscribeTimeline(
listener: (event: TimelineEvent) => void,
): Unsubscribe {
return listen(this.timelineListeners, listener);
}
/** A room event arrives from another user (or is echoed back). */
public emitTimelineEvent(event: TimelineEvent): void {
this.timeline.push(event);
for (const l of this.timelineListeners) l(event);
}
public getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[] {
const redacted = new Set(
this.timeline.flatMap((e) => (e.redacts ? [e.redacts] : [])),
);
return this.timeline.filter((e) => {
if (e.type !== eventType || redacted.has(e.eventId)) return false;
const relation = e.content["m.relates_to"] as
| { rel_type?: string; event_id?: string }
| undefined;
return relation?.rel_type === relType && relation.event_id === eventId;
});
}
// --- profile, media, features --------------------------------------------
public getOwnProfile(): OwnProfile {
return this.ownProfile;
}
public subscribeOwnProfile(
listener: (profile: OwnProfile) => void,
): Unsubscribe {
return listen(this.profileListeners, listener);
}
public setOwnProfile(profile: Partial<OwnProfile>): void {
this.ownProfile = { ...this.ownProfile, ...profile };
for (const l of this.profileListeners) l(this.ownProfile);
}
public async setDisplayName(name: string): Promise<void> {
this.setOwnProfile({ displayName: name });
return Promise.resolve();
}
public async thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
_resizeMethod: "crop" | "scale",
): Promise<string | null> {
return Promise.resolve(
mxcUrl.startsWith("mxc://")
? `https://media.example.org/thumbnail/${mxcUrl.slice("mxc://".length)}?width=${width}&height=${height}`
: null,
);
}
public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
return Promise.resolve(this.features);
}
public setMatrixClientFeatures(
features: Partial<MatrixClientFeatures>,
): void {
this.features = { ...this.features, ...features };
}
}
function listen<T>(listeners: Set<T>, listener: T): Unsubscribe {
listeners.add(listener);
return (): void => {
listeners.delete(listener);
};
}
-177
View File
@@ -1,177 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeAll, describe, expect, it } from "vitest";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import {
FfiElementCallCompat,
FfiMatrixDriver,
FfiMembershipState,
FfiParticipationManager,
FfiStatus,
FfiTransportIntent,
type FfiParticipationConfig,
} from "../matrix-rtc-sdk";
import {
MOCK_LK_SERVICE_URL,
MOCK_OWN_USER_ID,
MOCK_SLOT_ID,
MockRtcMatrixDriver,
roomEncryptionEvent,
slotEvent,
waitFor,
} from "./MockRtcMatrixDriver";
const config: FfiParticipationConfig = {
compat: FfiElementCallCompat.Off,
manageMediaKeys: true,
requireCrossSignedSender: false,
useKeyDelayMs: 50n,
};
const joinParams = {
applicationType: "m.call",
intent: undefined,
stickyDurationMs: 240_000n,
keepAliveTimeoutMs: 15_000n,
degradedLifetimeMs: undefined,
delegateDelayedLeave: false,
delegatedDelayMs: 3_600_000n,
};
const publish = (): FfiTransportIntent =>
new FfiTransportIntent.Publish({
transport: {
transportType: "livekit",
propertiesJson: JSON.stringify({
livekit_service_url: MOCK_LK_SERVICE_URL,
}),
},
});
function newManager(driver: MockRtcMatrixDriver): FfiParticipationManager {
return new FfiParticipationManager(
driver.roomId,
MOCK_SLOT_ID,
driver.userId,
driver.deviceId,
new FfiMatrixDriver(driver),
config,
);
}
describe("MockRtcMatrixDriver as the crate's driver", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("echoes our sticky join so our own membership reaches the roster", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const manager = newManager(driver);
await manager.join(publish(), joinParams);
expect(FfiStatus.Connected.instanceOf(manager.status())).toBe(true);
const sticky = driver.calls("stickyEvent");
expect(sticky).toHaveLength(1);
expect(sticky[0].eventType).toBe("org.matrix.msc4143.rtc.member");
const me = manager
.memberships()
.find((m) => m.member.userId === MOCK_OWN_USER_ID);
expect(me?.state).toBe(FfiMembershipState.Joined);
expect(me?.connections).toEqual([MOCK_LK_SERVICE_URL]);
expect(me?.transportIdentity).toBe(manager.ownTransportIdentity());
// the token the mock minted is what the crate hands out
expect(manager.connections()[0].connection.jwtToken).toBe(
`jwt-for-${MOCK_LK_SERVICE_URL}`,
);
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("hosts peers that join, answer our key and leave", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [
roomEncryptionEvent(),
slotEvent({ status: "open", encrypted: true }),
],
});
const manager = newManager(driver);
const peer = driver.addPeer({
userId: "@peer:example.org",
deviceId: "PEERDEV",
memberId: "m-peer",
});
await manager.join(
new FfiTransportIntent.ReceiveOnly({ canSubscribe: ["livekit"] }),
joinParams,
);
driver.peerJoins(peer);
expect(manager.memberships().map((m) => m.member.userId)).toContain(
peer.userId,
);
await waitFor("key exchange", () =>
manager.keyMap().some((k) => k.memberId === peer.memberId),
);
// spec MSC4143: the key message Element X's crates read too
expect(driver.calls("toDevice")[0].eventType).toBe(
"org.matrix.msc4143.rtc.encryption_key",
);
// the tile carries the sender's MSC4153 verdict even with the check off
expect(
manager.memberships().find((m) => m.member.memberId === peer.memberId)
?.mediaKey?.senderCrossSigned,
).toBe(true);
driver.peerLeaves(peer);
expect(
manager.memberships().find((m) => m.member.memberId === peer.memberId)
?.state,
).toBe(FfiMembershipState.LeftWithKeys);
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("reports homeserver connectivity into the crate", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
const manager = newManager(driver);
expect(manager.isHomeserverConnected()).toBe(true);
await manager.join(publish(), joinParams);
driver.setHomeserverConnected(false);
await waitFor("outage reported", () => !manager.isHomeserverConnected());
await waitFor("impairment", () => {
const status = manager.status();
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments[0]?.tag === "HomeserverUnreachable"
);
});
driver.setHomeserverConnected(true);
await waitFor("outage clears", () => {
const status = manager.status();
return (
FfiStatus.Connected.instanceOf(status) &&
status.inner.impairments.length === 0
);
});
await manager.leave(undefined, undefined);
manager.uniffiDestroy();
});
it("can refuse sticky and delayed events like an old homeserver", async () => {
const driver = new MockRtcMatrixDriver({
roomState: [slotEvent({ status: "open" })],
});
driver.refuseStickyEvents = true;
const manager = newManager(driver);
await expect(manager.join(publish(), joinParams)).rejects.toThrow();
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
manager.uniffiDestroy();
});
});
-676
View File
@@ -1,676 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* An {@link RtcMatrixDriver} with a homeserver made of arrays, for tests and
* stories.
*
* It models what sync would do: it records every outbound call, echoes
* accepted sticky and state events back through the room-event sink (so our
* own membership reaches the roster like anybody else's), answers `readState`
* from `roomState`, mints tokens, reports homeserver connectivity, and hosts
* simulated peers that answer our media key with theirs.
*
* A port of the crate's `web-test-app/src/mockDriver.ts`, which is the
* source of truth for the fabricated wire shapes below.
*/
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type FfiHomeserverDelegationRequest,
type FfiTransportDelegationRequest,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../matrix-rtc-sdk";
import { type RtcMatrixDriver } from "./RtcMatrixDriver";
export const MOCK_LK_SERVICE_URL = "https://lk.example.org";
export const MOCK_ROOM_ID = "!room:example.org";
/** MSC4143: a slot id is `{application_type}#{id}`; Element Call's is this. */
export const MOCK_SLOT_ID = "m.call#ROOM";
export const MOCK_OWN_USER_ID = "@me:example.org";
export const MOCK_OWN_DEVICE_ID = "MYDEV";
/** Every call the crate made on the driver, in order. */
export type OutboundCall =
| {
kind: "stickyEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
durationMs: bigint;
}
| {
kind: "stateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
}
| {
kind: "delayedEvent";
roomId: string;
eventType: string;
content: Record<string, unknown>;
delayMs: bigint;
stickyDurationMs: bigint | undefined;
delayId: string;
}
| {
kind: "delayedStateEvent";
roomId: string;
eventType: string;
stateKey: string;
content: Record<string, unknown>;
delayMs: bigint;
delayId: string;
}
| { kind: "restartDelayed"; roomId: string; delayId: string }
| { kind: "cancelDelayed"; roomId: string; delayId: string }
| { kind: "delegateViaHomeserver"; request: FfiHomeserverDelegationRequest }
| { kind: "delegateViaTransport"; request: FfiTransportDelegationRequest }
| {
kind: "toDevice";
recipients: FfiToDeviceRecipient[];
eventType: string;
content: Record<string, unknown>;
}
| { kind: "getRtcTransports" }
| {
kind: "getLivekitToken";
url: string;
roomId: string;
slotId: string;
member: Record<string, unknown>;
legacySfuGet: boolean;
};
/** A simulated remote participant. */
export interface RemotePeer {
userId: string;
deviceId: string;
memberId: string;
/** 32 key bytes; defaults to a constant pattern. */
key?: Uint8Array;
}
/** A raw Matrix event as the crate reads it. */
export type RawEvent = Record<string, unknown>;
export interface MockRtcMatrixDriverOptions {
userId?: string;
deviceId?: string;
roomId?: string;
/** Room state answered by `readState` (the crate's session seed). */
roomState?: RawEvent[];
/** Advertised by `getRtcTransports`; an empty list is "none". */
transports?: FfiRtcTransport[];
}
export class MockRtcMatrixDriver implements RtcMatrixDriver {
/** Who this driver publishes as — not part of the contract, handy in tests. */
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
public readonly outbound: OutboundCall[] = [];
/** Refuse delayed events like a homeserver without MSC4140 (404). */
public refuseDelayedEvents = false;
/** Refuse sticky events like a homeserver without MSC4354 (404). */
public refuseStickyEvents = false;
/** Make `getRtcTransports` fail rather than answer. */
public failTransportDiscovery = false;
/** No MSC4195 endpoint on the homeserver: the crate falls back to the service. */
public refuseHomeserverDelegation = false;
/** The authorisation service refuses the delegation too. */
public refuseTransportDelegation = false;
public roomState: RawEvent[];
public transports: FfiRtcTransport[];
/** Simulated peers answer our media key with theirs (index 0). */
public readonly peers: RemotePeer[] = [];
private roomEventSink?: RoomEventSinkLike;
private toDeviceSink?: ToDeviceSinkLike;
private stateUpdateSink?: StateUpdateSinkLike;
private readonly connectivitySinks = new Set<ConnectivitySinkLike>();
private homeserverConnected = true;
private nextDelayId = 0;
private nextEventId = 0;
public constructor(options: MockRtcMatrixDriverOptions = {}) {
this.userId = options.userId ?? MOCK_OWN_USER_ID;
this.deviceId = options.deviceId ?? MOCK_OWN_DEVICE_ID;
this.roomId = options.roomId ?? MOCK_ROOM_ID;
this.roomState = options.roomState ?? [];
this.transports = options.transports ?? [
{
transportType: "livekit",
propertiesJson: JSON.stringify({
livekit_service_url: MOCK_LK_SERVICE_URL,
}),
},
];
}
// --- assertions ----------------------------------------------------------
public calls<K extends OutboundCall["kind"]>(
kind: K,
): Extract<OutboundCall, { kind: K }>[] {
return this.outbound.filter((c) => c.kind === kind) as Extract<
OutboundCall,
{ kind: K }
>[];
}
// --- outbound --------------------------------------------------------------
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({
kind: "stickyEvent",
roomId,
eventType,
content,
durationMs,
});
if (this.refuseStickyEvents)
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: sticky events are not supported",
);
const eventId = this.eventId();
// The homeserver echoes our event through sync.
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: Number(durationMs) },
content,
},
new FfiEventOrigin.Encrypted({ senderDeviceId: this.deviceId }),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
const content = parse(contentJson);
this.record({ kind: "stateEvent", roomId, eventType, stateKey, content });
const eventId = this.eventId();
this.echo(
{
type: eventType,
sender: this.userId,
event_id: eventId,
room_id: roomId,
state_key: stateKey,
origin_server_ts: Date.now(),
content,
},
new FfiEventOrigin.Cleartext(),
);
return Promise.resolve({ eventId, delayId: undefined });
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedEvent",
roomId,
eventType,
content: parse(contentJson),
delayMs,
stickyDurationMs,
delayId,
});
if (this.refuseDelayedEvents)
// 404 M_UNRECOGNIZED: "this homeserver will never do delayed events".
throw new RtcError.Unsupported(
"M_UNRECOGNIZED: delayed events are not supported",
);
return Promise.resolve(delayId);
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
const delayId = `delay-${this.nextDelayId++}`;
this.record({
kind: "delayedStateEvent",
roomId,
eventType,
stateKey,
content: parse(contentJson),
delayMs,
delayId,
});
return Promise.resolve(delayId);
}
public async restartDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "restartDelayed", roomId, delayId });
return Promise.resolve();
}
public async cancelDelayedEvent(
roomId: string,
delayId: string,
): Promise<void> {
this.record({ kind: "cancelDelayed", roomId, delayId });
return Promise.resolve();
}
public async delegateDelayedLeaveViaHomeserver(
request: FfiHomeserverDelegationRequest,
): Promise<void> {
this.record({ kind: "delegateViaHomeserver", request });
if (this.refuseHomeserverDelegation)
throw new RtcError.Unsupported("M_UNRECOGNIZED: no delegation endpoint");
return Promise.resolve();
}
public async delegateDelayedLeaveViaTransport(
request: FfiTransportDelegationRequest,
): Promise<void> {
this.record({ kind: "delegateViaTransport", request });
if (this.refuseTransportDelegation)
throw new RtcError.Http("503: the service refused");
return Promise.resolve();
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
this.record({
kind: "toDevice",
recipients,
eventType,
content: parse(contentJson),
});
// Simulated peers answer with their own key.
for (const recipient of recipients) {
const peer = this.peers.find(
(p) =>
p.userId === recipient.userId && p.deviceId === recipient.deviceId,
);
if (peer) queueMicrotask(() => this.peerSendsKey(peer, 0));
}
// every recipient reachable
return Promise.resolve(
recipients.map((recipient) => ({ recipient, error: undefined })),
);
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
this.record({ kind: "getRtcTransports" });
if (this.failTransportDiscovery)
throw new RtcError.Http("500: transports endpoint unavailable");
return Promise.resolve(this.transports);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
this.record({
kind: "getLivekitToken",
url: request.url,
roomId: request.roomId,
slotId: request.slotId,
member: parse(request.memberJson),
legacySfuGet: request.legacySfuGet,
});
return Promise.resolve({
jwt: "jwt-for-" + request.url,
url: request.url.replace("https", "wss"),
});
}
public async readEvents(): Promise<string[]> {
return Promise.resolve([]);
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
return Promise.resolve(
this.roomState
.filter(
(e) =>
e.type === eventType &&
(stateKey === undefined || e.state_key === stateKey),
)
.map((e) => JSON.stringify(e)),
);
}
// --- inbound sinks -----------------------------------------------------------
// The crate subscribes exactly once, when its FfiMatrixDriver is built, and
// hands over sinks; a real driver hooks client listeners onto them. The
// mock stores them so tests can emit fabricated events.
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
this.roomEventSink = sink;
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
this.toDeviceSink = sink;
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
this.stateUpdateSink = sink;
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
this.connectivitySinks.add(sink);
}
public isHomeserverConnected(): boolean {
return this.homeserverConnected;
}
/** The homeserver comes or goes, as a syncing client would report it. */
public setHomeserverConnected(connected: boolean): void {
this.homeserverConnected = connected;
for (const sink of this.connectivitySinks)
if (!sink.emit(connected)) this.connectivitySinks.delete(sink);
}
/** Emit any room event — sticky or state; the crate dispatches on type. */
public emitRoomEvent(event: RawEvent, origin: FfiEventOrigin): boolean {
if (!this.roomEventSink)
throw new Error("The SDK has not subscribed to room events");
return this.roomEventSink.emit(JSON.stringify(event), origin);
}
/** `senderCrossSigned` is the MSC4153 verdict; peers are cross-signed by default. */
public emitToDevice(
eventType: string,
sender: string,
content: RawEvent,
origin: FfiEventOrigin,
senderCrossSigned: boolean | undefined = true,
): boolean {
if (!this.toDeviceSink)
throw new Error("The SDK has not subscribed to to-device events");
return this.toDeviceSink.emit(
eventType,
sender,
JSON.stringify(content),
origin,
senderCrossSigned,
);
}
public emitStateUpdate(events: RawEvent[]): boolean {
if (!this.stateUpdateSink)
throw new Error("The SDK has not subscribed to state updates");
return this.stateUpdateSink.emit(events.map((e) => JSON.stringify(e)));
}
// --- simulated peers --------------------------------------------------------
public addPeer(peer: RemotePeer): RemotePeer {
this.peers.push(peer);
return peer;
}
/** The peer publishes a join (on `MOCK_LK_SERVICE_URL` unless given). */
public peerJoins(
peer: RemotePeer,
opts: { lkServiceUrl?: string; durationMs?: number } = {},
): boolean {
return this.emitRoomEvent(
memberJoinEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
...opts,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerLeaves(peer: RemotePeer): boolean {
return this.emitRoomEvent(
memberLeaveEvent({
roomId: this.roomId,
userId: peer.userId,
memberId: peer.memberId,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
public peerSendsKey(peer: RemotePeer, index: number): boolean {
return this.emitToDevice(
"m.rtc.encryption_key",
peer.userId,
encryptionKeyContent({
roomId: this.roomId,
memberId: peer.memberId,
index,
key: peer.key,
}),
new FfiEventOrigin.Encrypted({ senderDeviceId: peer.deviceId }),
);
}
// --- internals -----------------------------------------------------------------
private record(call: OutboundCall): void {
this.outbound.push(call);
}
private eventId(): string {
return `$echo-${this.nextEventId++}`;
}
private echo(event: RawEvent, origin: FfiEventOrigin): void {
this.roomEventSink?.emit(JSON.stringify(event), origin);
}
}
function parse(json: string): Record<string, unknown> {
return JSON.parse(json) as Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// Inbound event fabrication — the MSC4143/MSC4354 wire shapes the crate's
// dispatch reads (see its src/session/dispatch.rs). Adjust here, not in
// every test.
// ---------------------------------------------------------------------------
let eventCounter = 0;
export function memberJoinEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
lkServiceUrl?: string;
durationMs?: number;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: opts.durationMs ?? 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
// MSC4354: the sticky key lives in the content and equals member.id.
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "join" },
application: { type: "m.call" },
transports: {
published: [
{
type: "livekit",
livekit_service_url: opts.lkServiceUrl ?? MOCK_LK_SERVICE_URL,
},
],
can_subscribe: ["livekit"],
},
},
};
}
export function memberLeaveEvent(opts: {
roomId?: string;
userId: string;
memberId: string;
}): RawEvent {
return {
type: "m.rtc.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: 240_000 },
content: {
slot_id: MOCK_SLOT_ID,
msc4354_sticky_key: opts.memberId,
member: { id: opts.memberId, membership: "leave" },
leave_reason: { code: "leave" },
},
};
}
export function slotEvent(
opts: { roomId?: string; status: "open" | "closed"; encrypted?: boolean } = {
status: "open",
},
): RawEvent {
const content: Record<string, unknown> = {
status: opts.status,
application: { type: "m.call" },
};
if (opts.encrypted) content.encryption = { type: "m.per_member" };
return {
type: "m.rtc.slot",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: MOCK_SLOT_ID,
origin_server_ts: Date.now(),
content,
};
}
export function roomEncryptionEvent(roomId = MOCK_ROOM_ID): RawEvent {
return {
type: "m.room.encryption",
sender: "@admin:example.org",
event_id: `$ev-${eventCounter++}`,
room_id: roomId,
state_key: "",
origin_server_ts: Date.now(),
content: { algorithm: "m.megolm.v1.aes-sha2" },
};
}
/** `m.room.member` state with the profile fields the crate puts on a member. */
export function roomMemberEvent(opts: {
roomId?: string;
userId: string;
membership?: "join" | "invite" | "leave";
displayName?: string;
avatarUrl?: string;
}): RawEvent {
const content: Record<string, unknown> = {
membership: opts.membership ?? "join",
};
if (opts.displayName !== undefined) content.displayname = opts.displayName;
if (opts.avatarUrl !== undefined) content.avatar_url = opts.avatarUrl;
return {
type: "m.room.member",
sender: opts.userId,
event_id: `$ev-${eventCounter++}`,
room_id: opts.roomId ?? MOCK_ROOM_ID,
state_key: opts.userId,
origin_server_ts: Date.now(),
content,
};
}
const DEFAULT_KEY = new Uint8Array(32).fill(7);
function base64(bytes: Uint8Array): string {
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
return btoa(binary).replace(/=+$/, "");
}
/** MSC4143 `m.rtc.encryption_key` content. */
export function encryptionKeyContent(opts: {
roomId?: string;
memberId: string;
index: number;
key?: Uint8Array;
}): RawEvent {
return {
room_id: opts.roomId ?? MOCK_ROOM_ID,
member_id: opts.memberId,
media_key: { index: opts.index, key: base64(opts.key ?? DEFAULT_KEY) },
format: 0,
};
}
/** One timer tick: the crate's listener callbacks arrive after the emitting task yields. */
export const tick = async (): Promise<void> =>
new Promise<void>((resolve) => setTimeout(resolve, 0));
export async function waitFor(
what: string,
cond: () => boolean,
timeoutMs = 3000,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (!cond()) {
if (Date.now() > deadline)
throw new Error(`Timed out waiting for: ${what}`);
await new Promise((r) => setTimeout(r, 10));
}
}
-21
View File
@@ -1,21 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The MatrixRTC half of what a host supplies: the `matrix-rtc` crate's own
* driver contract, taken verbatim from its bindings. Everything MatrixRTC —
* sticky and delayed events, to-device key delivery, transport tokens, the
* inbound event sinks and homeserver connectivity — goes through this and
* is consumed by the crate, never by Element Call directly.
*
* What a call needs from a Matrix client beyond MatrixRTC is the separate
* {@link ElementCallMatrixClientDriver}.
*/
import { type MatrixDriverCallback } from "../matrix-rtc-sdk";
export type RtcMatrixDriver = MatrixDriverCallback;
@@ -1,123 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { MatrixEvent, RoomEvent, UserEvent } from "matrix-js-sdk";
import { describe, expect, it, vi } from "vitest";
import { JsSdkElementCallMatrixClientDriver } from "./JsSdkElementCallMatrixClientDriver";
import {
ME,
ROOM_ID,
asClient,
asRoom,
fakeClient,
fakeRoom,
} from "./jsSdkTestFakes";
describe("JsSdkElementCallMatrixClientDriver", () => {
it("serves room info, members, timeline and profile from a full client", async () => {
const client = fakeClient(false);
const room = fakeRoom();
const driver = new JsSdkElementCallMatrixClientDriver(
asClient(client),
asRoom(room),
);
expect(driver.userId).toBe(ME);
expect(driver.getRoomInfo()).toEqual({
name: "Standup",
canonicalAlias: "#standup:example.org",
avatarUrl: "mxc://example.org/room",
joinRule: "public",
encrypted: true,
canOpenSlot: true,
});
const onInfo = vi.fn();
const offInfo = driver.subscribeRoomInfo(onInfo);
room.name = "Retro";
room.emit(RoomEvent.Name, room);
expect(onInfo).toHaveBeenCalledWith(
expect.objectContaining({ name: "Retro" }),
);
offInfo();
expect(driver.getRoomMembers()).toEqual([
{
userId: "@a:example.org",
displayName: "Alice",
avatarUrl: "mxc://example.org/alice",
membership: "join",
},
{
userId: "@b:example.org",
displayName: null,
avatarUrl: null,
membership: "invite",
},
]);
const seen: string[] = [];
driver.subscribeTimeline((e) => seen.push(`${e.type}:${e.eventId}`));
const reaction = new MatrixEvent({
type: "m.reaction",
sender: "@a:example.org",
event_id: "$r1",
room_id: ROOM_ID,
origin_server_ts: 3,
content: { "m.relates_to": { rel_type: "m.annotation", key: "🖐️" } },
});
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
client.emit(RoomEvent.Timeline, reaction, room, false, false, {});
await vi.waitFor(() => expect(seen).toEqual(["m.reaction:$r1"]));
await driver.sendRoomEvent("io.element.call.reaction", { emoji: "🎉" });
expect(client.sendEvent).toHaveBeenCalledWith(
ROOM_ID,
"io.element.call.reaction",
{ emoji: "🎉" },
);
await driver.redactEvent("$r1");
expect(client.redactEvent).toHaveBeenCalledWith(ROOM_ID, "$r1");
expect(driver.getOwnProfile()).toEqual({
displayName: "Me",
avatarUrl: "mxc://example.org/me",
});
const onProfile = vi.fn();
driver.subscribeOwnProfile(onProfile);
const user = client.getUser(ME)!;
user.rawDisplayName = "Moi";
user.emit(UserEvent.DisplayName, undefined, user);
expect(onProfile).toHaveBeenCalledWith(
expect.objectContaining({ displayName: "Moi" }),
);
await expect(driver.getMatrixClientFeatures()).resolves.toEqual({
stickyEvents: true,
verifiedEventOrigins: true,
crossSigningVerdicts: true,
});
await expect(driver.getDiagnostics()).resolves.toMatchObject({
matrix_backend: "jssdk",
crypto_version: "fake 1.0",
});
});
it("says what a widget client cannot vouch for and leaves media to the host bridge", async () => {
const driver = new JsSdkElementCallMatrixClientDriver(
asClient(fakeClient(true)),
asRoom(fakeRoom()),
);
await expect(driver.getMatrixClientFeatures()).resolves.toMatchObject({
verifiedEventOrigins: false,
crossSigningVerdicts: false,
});
// no token of its own: media comes through the host bridge instead
await expect(
driver.thumbnailUrl("mxc://example.org/x", 96, 96, "crop"),
).resolves.toBeNull();
});
});
@@ -1,333 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* An {@link ElementCallMatrixClientDriver} over a matrix-js-sdk client: room
* metadata and members, the room's timeline for reactions and notifications,
* the user's own profile, authenticated thumbnails and feature probes.
* Works on a full `MatrixClient` and on a `RoomWidgetClient`; the places
* they differ are marked "widget".
*/
import {
KnownMembership,
type MatrixClient,
type MatrixEvent,
type Room,
RoomEvent,
type RoomMember,
RoomStateEvent,
RoomWidgetClient,
UNSTABLE_MSC4354_STICKY_EVENTS,
type User,
UserEvent,
} from "matrix-js-sdk";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { ELEMENT_CALL_SLOT_EVENT_TYPE } from "../../state/rtc/slot";
import {
type MatrixClientFeatures,
type ElementCallMatrixClientDriver,
type OwnProfile,
type RoomInfo,
type RoomMemberProfile,
type TimelineEvent,
type Unsubscribe,
} from "../ElementCallMatrixClientDriver";
/** The state event types `getRoomInfo()` is computed from. */
const ROOM_INFO_EVENT_TYPES = new Set([
"m.room.name",
"m.room.avatar",
"m.room.canonical_alias",
"m.room.join_rules",
"m.room.encryption",
"m.room.power_levels",
]);
export interface JsSdkElementCallMatrixClientDriverOptions {
logger?: Logger;
}
export class JsSdkElementCallMatrixClientDriver implements ElementCallMatrixClientDriver {
public readonly userId: string;
public readonly deviceId: string;
public readonly roomId: string;
private readonly logger: Logger;
/** Widget: no crypto backend, no access token, events without metadata. */
private readonly widget: boolean;
private features: Promise<MatrixClientFeatures> | null = null;
public constructor(
private readonly client: MatrixClient,
private readonly room: Room,
options: JsSdkElementCallMatrixClientDriverOptions = {},
) {
const userId = client.getUserId();
const deviceId = client.getDeviceId();
if (userId === null || deviceId === null)
throw new Error(
"The client must be logged in before it can drive a call",
);
this.userId = userId;
this.deviceId = deviceId;
this.roomId = room.roomId;
this.widget = client instanceof RoomWidgetClient;
this.logger = (options.logger ?? rootLogger).getChild(
`[JsSdkElementCallMatrixClientDriver ${room.roomId}]`,
);
}
// --- room ------------------------------------------------------------------
public getRoomInfo(): RoomInfo {
return {
name: this.room.name,
canonicalAlias: this.room.getCanonicalAlias(),
avatarUrl: this.room.getMxcAvatarUrl(),
joinRule: this.room.currentState.getJoinRule() ?? null,
encrypted: this.room.hasEncryptionStateEvent(),
canOpenSlot: this.room.currentState.maySendStateEvent(
ELEMENT_CALL_SLOT_EVENT_TYPE,
this.userId,
),
};
}
public subscribeRoomInfo(listener: (info: RoomInfo) => void): Unsubscribe {
const notify = (): void => listener(this.getRoomInfo());
const onState = (event: MatrixEvent): void => {
if (
event.getRoomId() === this.roomId &&
ROOM_INFO_EVENT_TYPES.has(event.getType())
)
notify();
};
this.room.on(RoomEvent.Name, notify);
this.client.on(RoomStateEvent.Events, onState);
return (): void => {
this.room.off(RoomEvent.Name, notify);
this.client.off(RoomStateEvent.Events, onState);
};
}
public getRoomMembers(): RoomMemberProfile[] {
const profile =
(membership: "join" | "invite") =>
(member: RoomMember): RoomMemberProfile => ({
userId: member.userId,
displayName: member.rawDisplayName ?? null,
avatarUrl: member.getMxcAvatarUrl() ?? null,
membership,
});
return [
...this.room
.getMembersWithMembership(KnownMembership.Join)
.map(profile("join")),
...this.room
.getMembersWithMembership(KnownMembership.Invite)
.map(profile("invite")),
];
}
public subscribeRoomMembers(
listener: (members: RoomMemberProfile[]) => void,
): Unsubscribe {
const onMembers = (event: MatrixEvent): void => {
if (event.getRoomId() === this.roomId) listener(this.getRoomMembers());
};
this.client.on(RoomStateEvent.Members, onMembers);
return (): void => {
this.client.off(RoomStateEvent.Members, onMembers);
};
}
// --- timeline ----------------------------------------------------------------
public async sendRoomEvent(
eventType: string,
content: Record<string, unknown>,
): Promise<{ eventId: string }> {
const res = await this.client.sendEvent(
this.roomId,
eventType as never,
content as never,
);
return { eventId: res.event_id };
}
public async redactEvent(eventId: string): Promise<void> {
await this.client.redactEvent(this.roomId, eventId);
}
public subscribeTimeline(
listener: (event: TimelineEvent) => void,
): Unsubscribe {
// Our own events are seen twice — as the local echo, then as sent —
// and a redaction is both a timeline event and a Redaction signal.
const seen = new Set<string>();
const deliver = async (event: MatrixEvent): Promise<void> => {
if (event.getRoomId() !== this.roomId) return;
// Still sending: the LocalEchoUpdated listener gets the real id later.
if (event.status !== null) return;
if (event.unstableStickyInfo !== undefined || event.isState()) return;
const eventId = event.getId();
const sender = event.getSender();
if (!eventId || !sender || seen.has(eventId)) return;
try {
await this.client.decryptEventIfNeeded(event);
} catch (e) {
this.logger.warn(`Could not decrypt ${eventId}`, e);
}
if (event.isDecryptionFailure() || seen.has(eventId)) return;
seen.add(eventId);
if (seen.size > 1000) seen.delete(seen.values().next().value!);
listener({
eventId,
type: event.getType(),
sender,
content: event.getContent(),
originServerTs: event.getTs(),
redacts: event.event.redacts,
});
};
const onTimeline = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
): void => {
if (room?.roomId === this.roomId && !toStartOfTimeline)
void deliver(event);
};
const onEcho = (event: MatrixEvent): void => void deliver(event);
this.client.on(RoomEvent.Timeline, onTimeline);
this.room.on(RoomEvent.LocalEchoUpdated, onEcho);
this.room.on(RoomEvent.Redaction, onEcho);
return (): void => {
this.client.off(RoomEvent.Timeline, onTimeline);
this.room.off(RoomEvent.LocalEchoUpdated, onEcho);
this.room.off(RoomEvent.Redaction, onEcho);
};
}
public getRelatedEvents(
eventId: string,
relType: string,
eventType: string,
): TimelineEvent[] {
const relations = this.room.relations.getChildEventsForEvent(
eventId,
relType as never,
eventType as never,
);
return (relations?.getRelations() ?? [])
.filter(
(event) => !event.isRedacted() && event.getId() && event.getSender(),
)
.map((event) => ({
eventId: event.getId()!,
type: event.getType(),
sender: event.getSender()!,
content: event.getContent(),
originServerTs: event.getTs(),
}));
}
// --- profile -------------------------------------------------------------------
public getOwnProfile(): OwnProfile {
const user = this.client.getUser(this.userId);
return {
displayName: user?.rawDisplayName ?? null,
avatarUrl: user?.avatarUrl ?? null,
};
}
public subscribeOwnProfile(
listener: (profile: OwnProfile) => void,
): Unsubscribe {
const user: User | null = this.client.getUser(this.userId);
if (user === null) return (): void => {};
const notify = (): void => listener(this.getOwnProfile());
user.on(UserEvent.DisplayName, notify);
user.on(UserEvent.AvatarUrl, notify);
return (): void => {
user.off(UserEvent.DisplayName, notify);
user.off(UserEvent.AvatarUrl, notify);
};
}
public async setDisplayName(name: string): Promise<void> {
await this.client.setDisplayName(name);
}
public async setAvatar(file: Blob | null): Promise<void> {
if (file === null) {
await this.client.setAvatarUrl("");
return;
}
const { content_uri: uri } = await this.client.uploadContent(file);
await this.client.setAvatarUrl(uri);
}
// --- media -----------------------------------------------------------------------
public async thumbnailUrl(
mxcUrl: string,
width: number,
height: number,
resizeMethod: "crop" | "scale",
): Promise<string | null> {
const httpUrl = this.client.mxcUrlToHttp(
mxcUrl,
width,
height,
resizeMethod,
false,
true,
true,
);
// Widget: no token of our own; the host bridge downloads media instead.
const token = this.client.getAccessToken();
if (httpUrl === null || token === null) return null;
const response = await fetch(httpUrl, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) return null;
return URL.createObjectURL(await response.blob());
}
// --- features and diagnostics ------------------------------------------------
public async getMatrixClientFeatures(): Promise<MatrixClientFeatures> {
this.features ??= this.probeMatrixClientFeatures();
return this.features;
}
private async probeMatrixClientFeatures(): Promise<MatrixClientFeatures> {
const stickyEvents = await this.client
.doesServerSupportUnstableFeature(UNSTABLE_MSC4354_STICKY_EVENTS)
.catch((e: unknown) => {
this.logger.warn("Could not probe sticky event support", e);
return false;
});
return {
stickyEvents,
verifiedEventOrigins: !this.widget,
crossSigningVerdicts: this.client.getCrypto() !== undefined,
};
}
public async getDiagnostics(): Promise<Record<string, string>> {
return Promise.resolve({
matrix_backend: this.widget ? "widget" : "jssdk",
crypto_version: this.client.getCrypto()?.getVersion() ?? "none",
sync_state: String(this.client.getSyncState()),
});
}
}
@@ -1,518 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
ClientEvent,
MatrixError,
MatrixEvent,
RoomStateEvent,
RoomStickyEventsEvent,
SyncState,
UnsupportedStickyEventsEndpointError,
UpdateDelayedEventAction,
} from "matrix-js-sdk";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { initMatrixRtcSdkForTests } from "../../utils/test-matrix-rtc";
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../../matrix-rtc-sdk";
import { JsSdkRtcMatrixDriver } from "./JsSdkRtcMatrixDriver";
import {
LK,
ME,
MY_DEVICE,
ROOM_ID,
asClient,
asRoom,
fakeClient,
fakeRoom,
jsonResponse,
openIdToken,
type FakeClient,
type FakeRoom,
} from "./jsSdkTestFakes";
const memberJson = JSON.stringify({
id: "m-1",
claimed_user_id: ME,
claimed_device_id: MY_DEVICE,
});
let fetchMock: ReturnType<typeof vi.fn<typeof fetch>>;
describe("JsSdkRtcMatrixDriver", () => {
beforeEach(async () => {
// The bindings define the RtcError classes the driver throws.
await initMatrixRtcSdkForTests();
fetchMock = vi.fn<typeof fetch>();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("over a full MatrixClient", () => {
it("sends sticky, delayed and state events through the unstable APIs", async () => {
const { client, driver } = fullClient();
await expect(
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", '{"a":1}', 240_000n),
).resolves.toEqual({ eventId: "$sticky", delayId: undefined });
expect(client._unstable_sendStickyEvent).toHaveBeenCalledWith(
ROOM_ID,
240_000,
null,
"m.rtc.member",
{ a: 1 },
);
await expect(
driver.sendDelayedEvent(
ROOM_ID,
"m.rtc.member",
"{}",
15_000n,
240_000n,
),
).resolves.toBe("delay-sticky");
expect(client._unstable_sendStickyDelayedEvent).toHaveBeenCalledWith(
ROOM_ID,
240_000,
{ delay: 15_000 },
null,
"m.rtc.member",
{},
);
await expect(
driver.sendDelayedEvent(
ROOM_ID,
"m.rtc.member",
"{}",
15_000n,
undefined,
),
).resolves.toBe("delay-plain");
expect(client._unstable_sendDelayedEvent).toHaveBeenCalledWith(
ROOM_ID,
{ delay: 15_000 },
null,
"m.rtc.member",
{},
);
await driver.sendDelayedStateEvent(ROOM_ID, "m.x", "key", "{}", 5_000n);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
ROOM_ID,
{ delay: 5_000 },
"m.x",
{},
"key",
);
await driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "m.call#ROOM", "{}");
expect(client.sendStateEvent).toHaveBeenCalledWith(
ROOM_ID,
"m.rtc.slot",
{},
"m.call#ROOM",
);
await driver.restartDelayedEvent(ROOM_ID, "d1");
await driver.cancelDelayedEvent(ROOM_ID, "d1");
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
1,
"d1",
UpdateDelayedEventAction.Restart,
);
expect(client._unstable_updateDelayedEvent).toHaveBeenNthCalledWith(
2,
"d1",
UpdateDelayedEventAction.Cancel,
);
});
it("maps js-sdk failures onto the crate's error family", async () => {
const { client, driver } = fullClient();
client._unstable_sendStickyEvent.mockRejectedValueOnce(
new UnsupportedStickyEventsEndpointError("nope", "sendStickyEvent"),
);
await expect(
driver.sendStickyEvent(ROOM_ID, "m.rtc.member", "{}", 1n),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
client.sendStateEvent.mockRejectedValueOnce(
new MatrixError({ errcode: "M_FORBIDDEN" }, 403),
);
await expect(
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
).rejects.toSatisfy((e) => RtcError.Rejected.instanceOf(e));
client.sendStateEvent.mockRejectedValueOnce(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED", retry_after_ms: 1500 },
429,
),
);
await expect(
driver.sendStateEvent(ROOM_ID, "m.rtc.slot", "k", "{}"),
).rejects.toSatisfy(
(e) =>
RtcError.RateLimited.instanceOf(e) && e.inner.retryAfterMs === 1500n,
);
});
it("sends to-device messages Olm-encrypted per device", async () => {
const { client, driver } = fullClient();
const recipients = [{ userId: "@a:example.org", deviceId: "ADEV" }];
await expect(
driver.sendToDevice(recipients, "m.rtc.encryption_key", '{"k":1}'),
).resolves.toEqual([{ recipient: recipients[0], error: undefined }]);
expect(client.encryptAndSendToDevice).toHaveBeenCalledWith(
"m.rtc.encryption_key",
recipients,
{ k: 1 },
);
});
it("discovers transports through the client and answers in the crate's shape", async () => {
const { client, driver } = fullClient();
client._unstable_getRTCTransports.mockResolvedValue([
{ type: "livekit", livekit_service_url: LK },
]);
await expect(driver.getRtcTransports()).resolves.toEqual([
{
transportType: "livekit",
propertiesJson: JSON.stringify({ livekit_service_url: LK }),
},
]);
});
it("exchanges an OpenID token for a LiveKit token, on either endpoint", async () => {
const { driver } = fullClient();
// a fresh Response per call: a body can be read once
fetchMock.mockImplementation(async () =>
Promise.resolve(jsonResponse({ jwt: "the-jwt", url: "wss://sfu" })),
);
await expect(
driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: false,
}),
).resolves.toEqual({ jwt: "the-jwt", url: "wss://sfu" });
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(endpoint).toBe(`${LK}/get_token`);
expect(JSON.parse(init.body as string)).toEqual({
room_id: ROOM_ID,
slot_id: "m.call#ROOM",
openid_token: openIdToken,
member: JSON.parse(memberJson),
});
await driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: true,
});
const [legacyEndpoint, legacyInit] = fetchMock.mock
.calls[1] as unknown as [string, RequestInit];
expect(legacyEndpoint).toBe(`${LK}/sfu/get`);
expect(JSON.parse(legacyInit.body as string)).toEqual({
room: ROOM_ID,
openid_token: openIdToken,
device_id: MY_DEVICE,
});
fetchMock.mockResolvedValueOnce(new Response("gone", { status: 404 }));
await expect(
driver.getLivekitToken({
url: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
legacySfuGet: false,
}),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
});
it("offers both delegation primitives: the homeserver endpoint and the token endpoint", async () => {
const { client, driver } = fullClient();
client.http = { authedRequest: vi.fn(async () => Promise.resolve({})) };
await driver.delegateDelayedLeaveViaHomeserver({
sfuUrl: "wss://sfu.example.org",
livekitServiceUrl: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
delayId: "delay-1",
delayTimeoutMs: 3_600_000n,
});
expect(client.http.authedRequest).toHaveBeenCalledWith(
"POST",
"/rtc/livekit/delegate_delayed_leave",
undefined,
{
url: "wss://sfu.example.org",
room_id: ROOM_ID,
slot_id: "m.call#ROOM",
member: JSON.parse(memberJson),
delay_id: "delay-1",
delay_timeout: 3_600_000,
},
{ prefix: "/_matrix/client/unstable/io.element.msc4195" },
);
fetchMock.mockImplementation(async () =>
Promise.resolve(jsonResponse({ jwt: "discarded" })),
);
await driver.delegateDelayedLeaveViaTransport({
livekitServiceUrl: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
delayId: "delay-1",
delayTimeoutMs: 3_600_000n,
legacySfuGet: false,
});
const [endpoint, init] = fetchMock.mock.calls[0] as unknown as [
string,
RequestInit,
];
expect(endpoint).toBe(`${LK}/get_token`);
expect(JSON.parse(init.body as string)).toMatchObject({
delay_id: "delay-1",
delay_timeout: 3_600_000,
delay_cs_api_url: "https://hs.example.org",
});
});
it("feeds sticky events and state updates into the crate's sinks with their origin", async () => {
const { client, room, driver } = fullClient();
const roomSink = sink();
const stateSink = sink();
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
driver.subscribeStateUpdates(stateSink as unknown as StateUpdateSinkLike);
const sticky = new MatrixEvent({
type: "m.rtc.member",
sender: "@a:example.org",
event_id: "$s1",
room_id: ROOM_ID,
origin_server_ts: 1,
content: { slot_id: "m.call#ROOM" },
});
room.emit(RoomStickyEventsEvent.Update, [sticky], [], []);
await vi.waitFor(() => {
expect(roomSink.emit).toHaveBeenCalledTimes(1);
const [json, origin] = roomSink.emit.mock.calls[0] as unknown as [
string,
FfiEventOrigin,
];
expect(JSON.parse(json)).toMatchObject({
type: "m.rtc.member",
event_id: "$s1",
content: { slot_id: "m.call#ROOM" },
});
// not encrypted on this fake: the origin says so honestly
expect(FfiEventOrigin.Cleartext.instanceOf(origin)).toBe(true);
});
const slot = new MatrixEvent({
type: "m.rtc.slot",
sender: "@admin:example.org",
event_id: "$slot",
room_id: ROOM_ID,
state_key: "m.call#ROOM",
origin_server_ts: 2,
content: { status: "open" },
});
client.emit(RoomStateEvent.Events, slot, null, null);
expect(stateSink.emit).toHaveBeenCalledTimes(1);
expect(
JSON.parse(
(stateSink.emit.mock.calls[0] as unknown as [string[]])[0][0],
),
).toMatchObject({ type: "m.rtc.slot", state_key: "m.call#ROOM" });
// another room's state is not ours
client.emit(
RoomStateEvent.Events,
new MatrixEvent({ ...slot.event, room_id: "!other:example.org" }),
null,
null,
);
expect(stateSink.emit).toHaveBeenCalledTimes(1);
});
it("reports to-device messages with their Olm sender device and cross-signing verdict", async () => {
const { client, driver } = fullClient();
const toDevice = sink();
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
client.emit(ClientEvent.ReceivedToDeviceMessage, {
message: {
type: "m.rtc.encryption_key",
sender: "@a:example.org",
content: { member_id: "m-a" },
},
encryptionInfo: {
sender: "@a:example.org",
senderDevice: "ADEV",
senderCurve25519KeyBase64: "k",
},
});
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
const [type, sender, json, origin, crossSigned] = toDevice.emit.mock
.calls[0] as unknown as [
string,
string,
string,
FfiEventOrigin,
boolean,
];
expect(type).toBe("m.rtc.encryption_key");
expect(sender).toBe("@a:example.org");
expect(JSON.parse(json)).toEqual({ member_id: "m-a" });
expect(senderDeviceOf(origin)).toBe("ADEV");
expect(crossSigned).toBe(true);
});
it("reports homeserver connectivity from the sync state", () => {
const { client, driver } = fullClient();
expect(driver.isHomeserverConnected()).toBe(true);
const connectivity = sink();
driver.subscribeConnectivity(
connectivity as unknown as ConnectivitySinkLike,
);
client.getSyncState.mockReturnValue(SyncState.Error);
client.emit(ClientEvent.Sync, SyncState.Error, SyncState.Syncing);
expect(connectivity.emit).toHaveBeenCalledWith(false);
expect(driver.isHomeserverConnected()).toBe(false);
client.getSyncState.mockReturnValue(SyncState.Syncing);
client.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Error);
expect(connectivity.emit).toHaveBeenLastCalledWith(true);
});
});
describe("over a RoomWidgetClient", () => {
it("listens for the legacy to-device event and reports the claimed device", async () => {
const { client, driver } = widgetClient();
const toDevice = sink();
driver.subscribeToDeviceEvents(toDevice as unknown as ToDeviceSinkLike);
const event = new MatrixEvent({
type: "io.element.call.encryption_keys",
sender: "@a:example.org",
content: { member: { id: "m-a", claimed_device_id: "ADEV" } },
});
event.makeEncrypted("m.room.encrypted", {}, "", "");
client.emit(ClientEvent.ToDeviceEvent, event);
await vi.waitFor(() => expect(toDevice.emit).toHaveBeenCalledTimes(1));
const [, , , origin, crossSigned] = toDevice.emit.mock
.calls[0] as unknown as [
string,
string,
string,
FfiEventOrigin,
boolean | undefined,
];
expect(senderDeviceOf(origin)).toBe("ADEV");
expect(crossSigned).toBeUndefined();
});
it("cannot delegate through the homeserver, so the crate falls back to the service", async () => {
const { driver } = widgetClient();
await expect(
driver.delegateDelayedLeaveViaHomeserver({
sfuUrl: "wss://sfu.example.org",
livekitServiceUrl: LK,
roomId: ROOM_ID,
slotId: "m.call#ROOM",
memberJson,
delayId: "delay-1",
delayTimeoutMs: 3_600_000n,
}),
).rejects.toSatisfy((e) => RtcError.Unsupported.instanceOf(e));
});
it("treats member events in an encrypted room as encrypted by the claimed device", async () => {
const { room, driver } = widgetClient();
const roomSink = sink();
driver.subscribeRoomEvents(roomSink as unknown as RoomEventSinkLike);
room.emit(
RoomStickyEventsEvent.Update,
[
new MatrixEvent({
type: "m.rtc.member",
sender: "@a:example.org",
event_id: "$s1",
room_id: ROOM_ID,
origin_server_ts: 1,
content: { slot_id: "m.call#ROOM", member: { device_id: "ADEV" } },
}),
],
[],
[],
);
await vi.waitFor(() => expect(roomSink.emit).toHaveBeenCalledTimes(1));
const origin = (
roomSink.emit.mock.calls[0] as unknown as [string, FfiEventOrigin]
)[1];
expect(senderDeviceOf(origin)).toBe("ADEV");
});
});
});
function fullClient(): {
client: FakeClient;
room: FakeRoom;
driver: JsSdkRtcMatrixDriver;
} {
const client = fakeClient(false);
const room = fakeRoom();
return {
client,
room,
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
};
}
function widgetClient(): {
client: FakeClient;
room: FakeRoom;
driver: JsSdkRtcMatrixDriver;
} {
const client = fakeClient(true);
const room = fakeRoom();
return {
client,
room,
driver: new JsSdkRtcMatrixDriver(asClient(client), asRoom(room)),
};
}
type SinkEmit = ReturnType<typeof vi.fn<(...args: unknown[]) => boolean>>;
function sink(): { emit: SinkEmit } {
return { emit: vi.fn<(...args: unknown[]) => boolean>(() => true) };
}
/** The sender device an origin carries, if it is an encrypted one. */
function senderDeviceOf(origin: FfiEventOrigin): string | undefined {
return FfiEventOrigin.Encrypted.instanceOf(origin)
? origin.inner.senderDeviceId
: undefined;
}
-654
View File
@@ -1,654 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* An {@link RtcMatrixDriver} over a matrix-js-sdk client: the crate's
* MatrixRTC seam, nothing else. The standalone app, the widget and the SDK
* build hand this to Element Call, and a host that already runs matrix-js-sdk
* can use it as it is.
*
* It serves two kinds of client. A full `MatrixClient` syncs, decrypts and
* has an access token of its own. A `RoomWidgetClient` is a shell over the
* widget API: it never sees ciphertext (its host decrypts), has no crypto
* backend and no token, only emits the legacy to-device event, and answers
* transport discovery over the widget API. Every place the two differ is
* marked "widget".
*
* Adapted from the crate's `web-test-app/src/jsSdkDriver.ts`.
*/
import {
ClientEvent,
type IOpenIDToken,
type MatrixClient,
type MatrixEvent,
MatrixError,
Method,
type ReceivedToDeviceMessage,
type Room,
RoomEvent,
RoomStateEvent,
RoomStickyEventsEvent,
RoomWidgetClient,
SyncState,
UnsupportedDelayedEventsEndpointError,
UnsupportedStickyEventsEndpointError,
UpdateDelayedEventAction,
parseErrorResponse,
} from "matrix-js-sdk";
import { type Transport } from "matrix-js-sdk/lib/matrixrtc";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import {
FfiEventOrigin,
RtcError,
type ConnectivitySinkLike,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type FfiHomeserverDelegationRequest,
type FfiTransportDelegationRequest,
type RoomEventSinkLike,
type StateUpdateSinkLike,
type ToDeviceSinkLike,
} from "../../matrix-rtc-sdk";
import { doNetworkOperationWithRetry } from "../../utils/matrix";
import { type RtcMatrixDriver } from "../RtcMatrixDriver";
export interface JsSdkRtcMatrixDriverOptions {
logger?: Logger;
}
export class JsSdkRtcMatrixDriver implements RtcMatrixDriver {
private readonly roomId: string;
private readonly logger: Logger;
/** Widget: the host decrypts for us and there is no crypto backend. */
private readonly widget: boolean;
private readonly detachers: (() => void)[] = [];
/** curve25519 sender key → device id, per megolm-attributed sender. */
private readonly senderDeviceCache = new Map<string, string>();
public constructor(
private readonly client: MatrixClient,
private readonly room: Room,
options: JsSdkRtcMatrixDriverOptions = {},
) {
this.roomId = room.roomId;
this.widget = client instanceof RoomWidgetClient;
this.logger = (options.logger ?? rootLogger).getChild(
`[JsSdkRtcMatrixDriver ${room.roomId}]`,
);
}
/** Unhooks every client listener. The crate's sinks stop being fed. */
public detach(): void {
for (const detach of this.detachers.splice(0)) detach();
}
// --- outbound --------------------------------------------------------------
public async sendStickyEvent(
roomId: string,
eventType: string,
contentJson: string,
durationMs: bigint,
): Promise<FfiSendEventResponse> {
return guard(async () => {
const res = await this.client._unstable_sendStickyEvent(
roomId,
Number(durationMs),
null,
eventType as never,
JSON.parse(contentJson) as never,
);
return { eventId: res.event_id, delayId: undefined };
});
}
public async sendStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
): Promise<FfiSendEventResponse> {
return guard(async () => {
const res = await this.client.sendStateEvent(
roomId,
eventType as never,
JSON.parse(contentJson) as never,
stateKey,
);
return { eventId: res.event_id, delayId: undefined };
});
}
public async sendDelayedEvent(
roomId: string,
eventType: string,
contentJson: string,
delayMs: bigint,
stickyDurationMs: bigint | undefined,
): Promise<string> {
return guard(async () => {
const content = JSON.parse(contentJson) as never;
const res =
stickyDurationMs === undefined
? await this.client._unstable_sendDelayedEvent(
roomId,
{ delay: Number(delayMs) },
null,
eventType as never,
content,
)
: await this.client._unstable_sendStickyDelayedEvent(
roomId,
Number(stickyDurationMs),
{ delay: Number(delayMs) },
null,
eventType as never,
content,
);
return res.delay_id;
});
}
public async sendDelayedStateEvent(
roomId: string,
eventType: string,
stateKey: string,
contentJson: string,
delayMs: bigint,
): Promise<string> {
return guard(async () => {
const res = await this.client._unstable_sendDelayedStateEvent(
roomId,
{ delay: Number(delayMs) },
eventType as never,
JSON.parse(contentJson) as never,
stateKey,
);
return res.delay_id;
});
}
public async restartDelayedEvent(
_roomId: string,
delayId: string,
): Promise<void> {
await guard(async () =>
this.client._unstable_updateDelayedEvent(
delayId,
UpdateDelayedEventAction.Restart,
),
);
}
public async cancelDelayedEvent(
_roomId: string,
delayId: string,
): Promise<void> {
await guard(async () =>
this.client._unstable_updateDelayedEvent(
delayId,
UpdateDelayedEventAction.Cancel,
),
);
}
/**
* MSC4195 through the homeserver's CS API (a homeserver proxying
* `rtc/livekit/*` to the authorisation service, MSC4512). One
* authenticated request with the body lk-jwt-service 0.7 accepts; the
* crate decides when to make it and what to try when it fails. Widget: the
* widget client has no access token, so this is `Unsupported` there.
*/
public async delegateDelayedLeaveViaHomeserver(
request: FfiHomeserverDelegationRequest,
): Promise<void> {
if (this.widget)
throw new RtcError.Unsupported(
"A widget client cannot make authenticated homeserver requests",
);
await guard(async () => {
await this.client.http.authedRequest(
Method.Post,
"/rtc/livekit/delegate_delayed_leave",
undefined,
{
// The SFU our token named; the service checks it is its own.
url: request.sfuUrl,
room_id: request.roomId,
slot_id: request.slotId,
member: JSON.parse(request.memberJson) as MemberClaims,
delay_id: request.delayId,
// Optional once the homeserver can be asked for the delay by id.
delay_timeout: Number(request.delayTimeoutMs),
},
{ prefix: "/_matrix/client/unstable/io.element.msc4195" },
);
});
}
/**
* MSC4195 through the authorisation service: the token request Element Call
* has always made, with `delay_id`, `delay_timeout` and this client's
* homeserver URL added. The token in the answer is discarded.
*/
public async delegateDelayedLeaveViaTransport(
request: FfiTransportDelegationRequest,
): Promise<void> {
await guard(async () => {
await this.requestToken(
request.livekitServiceUrl,
request.roomId,
request.slotId,
JSON.parse(request.memberJson) as MemberClaims,
request.legacySfuGet,
{
delay_id: request.delayId,
delay_timeout: Number(request.delayTimeoutMs),
delay_cs_api_url: this.client.baseUrl,
},
);
});
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
eventType: string,
contentJson: string,
): Promise<FfiToDeviceDelivery[]> {
return guard(async () => {
// Olm-encrypted, per specific device — never `*`. On a widget client
// this asks the host to encrypt; the plain `sendToDevice` there would
// go out in clear.
await this.client.encryptAndSendToDevice(
eventType,
recipients,
JSON.parse(contentJson) as Record<string, unknown>,
);
return recipients.map((recipient) => ({ recipient, error: undefined }));
});
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
return guard(async () => {
// The homeserver endpoint (MSC4143), or the widget host's answer to
// the same question (MSC4515) — the client knows which.
const transports = await doNetworkOperationWithRetry(async () =>
this.client._unstable_getRTCTransports(),
);
return transports.map(({ type, ...properties }: Transport) => ({
transportType: String(type),
propertiesJson: JSON.stringify(properties),
}));
});
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
return guard(async () => {
const token = await this.requestToken(
request.url,
request.roomId,
request.slotId,
JSON.parse(request.memberJson) as MemberClaims,
request.legacySfuGet,
);
return { jwt: token.jwt, url: token.url };
});
}
private async requestToken(
serviceUrl: string,
roomId: string,
slotId: string,
member: MemberClaims,
legacySfuGet: boolean,
delegation: Record<string, unknown> = {},
): Promise<{ jwt: string; url?: string }> {
let openIdToken: IOpenIDToken;
try {
openIdToken = await doNetworkOperationWithRetry(async () =>
this.client.getOpenIdToken(),
);
} catch (e) {
throw new RtcError.Http(`Could not get an OpenID token: ${String(e)}`);
}
const base = serviceUrl.replace(/\/$/, "");
const [endpoint, body] = legacySfuGet
? [
`${base}/sfu/get`,
{
// The legacy endpoint derives the LiveKit room alias from the
// Matrix room id alone.
room: roomId,
openid_token: openIdToken,
device_id: member.claimed_device_id,
...delegation,
},
]
: [
`${base}/get_token`,
{
room_id: roomId,
slot_id: slotId,
openid_token: openIdToken,
member,
...delegation,
},
];
const response = await doNetworkOperationWithRetry(async () =>
fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
);
if (!response.ok) {
const text = await response.text();
if (response.status === 404)
throw new RtcError.Unsupported(`${endpoint}: ${response.status}`);
throw parseErrorResponse(response, text);
}
return (await response.json()) as { jwt: string; url?: string };
}
// --- reads (the crate's session seed) ---------------------------------------
public async readEvents(
eventType: string,
_stateKey: string | undefined,
limit: number,
): Promise<string[]> {
const out: string[] = [];
for (const event of this.room._unstable_getStickyEvents()) {
await this.client.decryptEventIfNeeded(event);
if (event.getType() !== eventType) continue;
out.push(JSON.stringify(rawEvent(event)));
if (out.length >= limit) break;
}
return out;
}
public async readState(
eventType: string,
stateKey: string | undefined,
): Promise<string[]> {
const events =
stateKey === undefined
? this.room.currentState.getStateEvents(eventType)
: [this.room.currentState.getStateEvents(eventType, stateKey)].filter(
(e): e is MatrixEvent => Boolean(e),
);
return Promise.resolve(
events.map((event) => JSON.stringify(rawEvent(event))),
);
}
// --- inbound sinks ----------------------------------------------------------
public subscribeRoomEvents(sink: RoomEventSinkLike): void {
const emit = async (event: MatrixEvent): Promise<void> => {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
this.logger.warn(`Event ${event.getId()} failed to decrypt; skipped`);
return;
}
if (
!sink.emit(
JSON.stringify(rawEvent(event)),
await this.roomEventOrigin(event),
)
)
detach();
};
// Sticky events come from the room's sticky store: sync delivers them in
// the room's `msc4354_sticky` section, our own included.
const onSticky = (
added: MatrixEvent[],
updated: { current: MatrixEvent }[],
): void => {
for (const event of [...added, ...updated.map((u) => u.current)])
void emit(event);
};
// Everything else (state events in the timeline) comes from the timeline;
// local echoes and sticky events are skipped there.
const onTimeline = (
event: MatrixEvent,
room: Room | undefined,
toStartOfTimeline: boolean | undefined,
): void => {
if (room?.roomId !== this.roomId || toStartOfTimeline) return;
if (event.status !== null || event.unstableStickyInfo !== undefined)
return;
void emit(event);
};
const detach = (): void => {
this.room.off(RoomStickyEventsEvent.Update, onSticky);
this.client.off(RoomEvent.Timeline, onTimeline);
};
this.room.on(RoomStickyEventsEvent.Update, onSticky);
this.client.on(RoomEvent.Timeline, onTimeline);
this.detachers.push(detach);
}
public subscribeStateUpdates(sink: StateUpdateSinkLike): void {
// Client-level listener: the room-level re-emit is unreliable under
// MSC4222 `state_after` churn.
const onState = (event: MatrixEvent): void => {
if (event.getRoomId() !== this.roomId) return;
if (!sink.emit([JSON.stringify(rawEvent(event))])) detach();
};
const detach = (): void => {
this.client.off(RoomStateEvent.Events, onState);
};
this.client.on(RoomStateEvent.Events, onState);
this.detachers.push(detach);
}
public subscribeToDeviceEvents(sink: ToDeviceSinkLike): void {
if (this.widget) {
// Widget: the legacy event is the only one the widget client emits.
// The host decrypted the message; it says whether it was encrypted but
// not by which device, so the device is the one the content claims.
const onToDevice = (event: MatrixEvent): void => {
const content = event.getContent() as KeyMessageContent;
const origin = event.isEncrypted()
? new FfiEventOrigin.Encrypted({
senderDeviceId: claimedKeyDevice(content),
})
: new FfiEventOrigin.Cleartext();
if (
!sink.emit(
event.getType(),
event.getSender() ?? "",
JSON.stringify(content),
origin,
undefined,
)
)
detach();
};
const detach = (): void => {
this.client.off(ClientEvent.ToDeviceEvent, onToDevice);
};
this.client.on(ClientEvent.ToDeviceEvent, onToDevice);
this.detachers.push(detach);
return;
}
const onToDevice = (received: ReceivedToDeviceMessage): void =>
void handleToDevice(received);
const handleToDevice = async ({
message,
encryptionInfo,
}: ReceivedToDeviceMessage): Promise<void> => {
const origin = encryptionInfo
? new FfiEventOrigin.Encrypted({
senderDeviceId: encryptionInfo.senderDevice,
})
: new FfiEventOrigin.Cleartext();
// MSC4153: is the sending device cross-signed by its owner?
let crossSigned: boolean | undefined;
const crypto = this.client.getCrypto();
if (crypto && encryptionInfo?.senderDevice) {
const status = await crypto.getDeviceVerificationStatus(
encryptionInfo.sender,
encryptionInfo.senderDevice,
);
crossSigned = status?.signedByOwner ?? false;
}
if (
!sink.emit(
message.type,
message.sender,
JSON.stringify(message.content ?? {}),
origin,
crossSigned,
)
)
detach();
};
const detach = (): void => {
this.client.off(ClientEvent.ReceivedToDeviceMessage, onToDevice);
};
this.client.on(ClientEvent.ReceivedToDeviceMessage, onToDevice);
this.detachers.push(detach);
}
// --- connectivity ------------------------------------------------------------
public isHomeserverConnected(): boolean {
// Widget: the widget client reports Syncing once it has seen an event
// and never anything else, so on a widget this is always true.
return this.client.getSyncState() === SyncState.Syncing;
}
public subscribeConnectivity(sink: ConnectivitySinkLike): void {
const onSync = (): void => {
if (!sink.emit(this.isHomeserverConnected())) detach();
};
const detach = (): void => {
this.client.off(ClientEvent.Sync, onSync);
};
this.client.on(ClientEvent.Sync, onSync);
this.detachers.push(detach);
}
private async roomEventOrigin(event: MatrixEvent): Promise<FfiEventOrigin> {
if (this.widget) {
// Widget: events arrive decrypted with no metadata. In an encrypted
// room they were encrypted, by the device the content claims — the
// same trust matrix-js-sdk's own session extends.
if (event.isState() || !this.room.hasEncryptionStateEvent())
return new FfiEventOrigin.Cleartext();
return new FfiEventOrigin.Encrypted({
senderDeviceId: claimedMemberDevice(event.getContent()),
});
}
if (!event.isEncrypted()) return new FfiEventOrigin.Cleartext();
return new FfiEventOrigin.Encrypted({
senderDeviceId: await this.senderDeviceOf(event),
});
}
/** The device that megolm-encrypted `event` (sender key → device list). */
private async senderDeviceOf(
event: MatrixEvent,
): Promise<string | undefined> {
const senderKey = event.getSenderKey();
const sender = event.getSender();
const crypto = this.client.getCrypto();
if (!senderKey || !sender || !crypto) return undefined;
const cached = this.senderDeviceCache.get(senderKey);
if (cached) return cached;
const devices = await crypto.getUserDeviceInfo([sender], true);
for (const device of devices.get(sender)?.values() ?? []) {
if (device.getIdentityKey() === senderKey) {
this.senderDeviceCache.set(senderKey, device.deviceId);
return device.deviceId;
}
}
return undefined;
}
}
/** The full (decrypted) event object the crate's dispatch reads. */
function rawEvent(event: MatrixEvent): Record<string, unknown> {
return {
...(event.event as Record<string, unknown>),
type: event.getType(),
content: event.getContent(),
sender: event.getSender(),
event_id: event.getId(),
room_id: event.getRoomId(),
origin_server_ts: event.getTs(),
state_key: event.getStateKey(),
};
}
/** MSC4195 member claims, as the crate serialises them. */
interface MemberClaims {
id: string;
claimed_user_id: string;
claimed_device_id: string;
}
/** The device a media key message claims to come from, in either dialect. */
interface KeyMessageContent {
member?: { claimed_device_id?: string };
device_id?: string;
}
function claimedKeyDevice(content: KeyMessageContent): string | undefined {
return content.member?.claimed_device_id ?? content.device_id;
}
/** The device a 2025-dialect membership claims (`member.device_id`). */
function claimedMemberDevice(
content: Record<string, unknown>,
): string | undefined {
const member = content.member as { device_id?: unknown } | undefined;
return typeof member?.device_id === "string" ? member.device_id : undefined;
}
/** Map js-sdk / HTTP failures onto the error the crate reasons about. */
function toRtcError(error: unknown): Error {
if (RtcError.instanceOf(error)) return error;
if (
error instanceof UnsupportedDelayedEventsEndpointError ||
error instanceof UnsupportedStickyEventsEndpointError
)
return new RtcError.Unsupported(String(error));
if (error instanceof MatrixError) {
if (error.errcode === "M_LIMIT_EXCEEDED")
return new RtcError.RateLimited({
retryAfterMs:
typeof error.data.retry_after_ms === "number"
? BigInt(error.data.retry_after_ms)
: undefined,
});
if (error.httpStatus === 404 || error.errcode === "M_UNRECOGNIZED")
return new RtcError.Unsupported(String(error));
if (error.httpStatus === 403 || error.errcode === "M_FORBIDDEN")
return new RtcError.Rejected(String(error));
return new RtcError.Http(String(error));
}
return new RtcError.Driver(String(error));
}
async function guard<T>(f: () => Promise<T>): Promise<T> {
try {
return await f();
} catch (error) {
throw toRtcError(error);
}
}
-151
View File
@@ -1,151 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
// Fakes shared by the js-sdk driver tests. The drivers tell the two client
// kinds apart by prototype, so each fake is created on the real prototype
// and given only the members it needs; EventEmitter sits at the bottom of
// that chain, so `on`/`emit` work.
import { EventEmitter } from "events";
import {
MatrixClient,
type Room,
RoomWidgetClient,
SyncState,
User,
} from "matrix-js-sdk";
import { vi } from "vitest";
export const ROOM_ID = "!room:example.org";
export const ME = "@me:example.org";
export const MY_DEVICE = "MYDEV";
export const LK = "https://lk.example.org";
export const openIdToken = {
access_token: "openid",
expires_in: 3600,
matrix_server_name: "example.org",
token_type: "Bearer",
};
type Fn = ReturnType<typeof vi.fn>;
export interface FakeClient extends EventEmitter {
_unstable_sendStickyEvent: Fn;
_unstable_sendDelayedEvent: Fn;
_unstable_sendStickyDelayedEvent: Fn;
_unstable_sendDelayedStateEvent: Fn;
_unstable_updateDelayedEvent: Fn;
_unstable_getRTCTransports: Fn;
sendStateEvent: Fn;
encryptAndSendToDevice: Fn;
sendEvent: Fn;
redactEvent: Fn;
getSyncState: Fn;
getUser: (userId: string) => User | null;
http: { authedRequest: Fn };
}
export interface FakeRoom extends EventEmitter {
name: string;
}
function clientMembers(widget: boolean): Record<string, unknown> {
const user = new User(ME);
user.rawDisplayName = "Me";
user.avatarUrl = "mxc://example.org/me";
const verification = { signedByOwner: true };
return {
baseUrl: "https://hs.example.org",
getUserId: () => ME,
getDeviceId: () => MY_DEVICE,
getAccessToken: () => (widget ? null : "token"),
getCrypto: () =>
widget
? undefined
: {
getVersion: () => "fake 1.0",
getDeviceVerificationStatus: async () =>
Promise.resolve(verification),
getUserDeviceInfo: async () => Promise.resolve(new Map()),
},
getSyncState: vi.fn(() => SyncState.Syncing),
getUser: () => user,
getOpenIdToken: async () => Promise.resolve(openIdToken),
decryptEventIfNeeded: async () => Promise.resolve(),
doesServerSupportUnstableFeature: async () => Promise.resolve(true),
mxcUrlToHttp: (mxc: string) => `https://hs.example.org/media/${mxc}`,
_unstable_sendStickyEvent: vi.fn(async () =>
Promise.resolve({ event_id: "$sticky" }),
),
_unstable_sendDelayedEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-plain" }),
),
_unstable_sendStickyDelayedEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-sticky" }),
),
_unstable_sendDelayedStateEvent: vi.fn(async () =>
Promise.resolve({ delay_id: "delay-state" }),
),
_unstable_updateDelayedEvent: vi.fn(async () => Promise.resolve({})),
_unstable_getRTCTransports: vi.fn(async () => Promise.resolve([])),
sendStateEvent: vi.fn(async () => Promise.resolve({ event_id: "$state" })),
encryptAndSendToDevice: vi.fn(async () => Promise.resolve()),
sendEvent: vi.fn(async () => Promise.resolve({ event_id: "$sent" })),
redactEvent: vi.fn(async () => Promise.resolve({ event_id: "$redaction" })),
};
}
export function fakeRoom(): FakeRoom {
const room = new EventEmitter() as FakeRoom;
const alice = {
userId: "@a:example.org",
rawDisplayName: "Alice",
getMxcAvatarUrl: () => "mxc://example.org/alice",
};
const bob = {
userId: "@b:example.org",
rawDisplayName: undefined,
getMxcAvatarUrl: () => undefined,
};
Object.assign(room, {
roomId: ROOM_ID,
name: "Standup",
getCanonicalAlias: () => "#standup:example.org",
getMxcAvatarUrl: () => "mxc://example.org/room",
hasEncryptionStateEvent: () => true,
currentState: {
getJoinRule: () => "public",
getStateEvents: () => [],
maySendStateEvent: () => true,
},
getMembersWithMembership: (membership: string) =>
membership === "join" ? [alice] : [bob],
_unstable_getStickyEvents: () => [],
relations: { getChildEventsForEvent: () => undefined },
});
return room;
}
export function fakeClient(widget: boolean): FakeClient {
const client = Object.create(
widget ? RoomWidgetClient.prototype : MatrixClient.prototype,
) as FakeClient;
Object.assign(client, clientMembers(widget));
return client;
}
export const asClient = (client: FakeClient): MatrixClient =>
client as unknown as MatrixClient;
export const asRoom = (room: FakeRoom): Room => room as unknown as Room;
export function jsonResponse(body: unknown): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
-37
View File
@@ -1,37 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient, type Room } from "matrix-js-sdk";
import { useEffect, useMemo } from "react";
import { type MatrixDrivers } from "../MatrixDriverContext";
import { JsSdkElementCallMatrixClientDriver } from "./JsSdkElementCallMatrixClientDriver";
import { JsSdkRtcMatrixDriver } from "./JsSdkRtcMatrixDriver";
/**
* The two matrix-js-sdk drivers for a room, for as long as the client and
* room stay the same. The RTC driver hooks listeners on the client for the
* crate's sinks; they are let go of when the drivers are replaced or the
* caller unmounts.
*/
export function useJsSdkDrivers(
client: MatrixClient,
room: Room,
): MatrixDrivers {
const drivers = useMemo(
(): MatrixDrivers => ({
rtcDriver: new JsSdkRtcMatrixDriver(client, room),
clientDriver: new JsSdkElementCallMatrixClientDriver(client, room),
}),
[client, room],
);
useEffect(
() => (): void => (drivers.rtcDriver as JsSdkRtcMatrixDriver).detach(),
[drivers],
);
return drivers;
}
-29
View File
@@ -1,29 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { Observable } from "rxjs";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import { type Unsubscribe } from "./ElementCallMatrixClientDriver";
/**
* Turns one of a driver's `get` / `subscribe` pairs into a behavior owned by
* `scope`: the current value now, every change until the scope ends.
*/
export function observeDriver<T>(
scope: ObservableScope,
get: () => T,
subscribe: (listener: (value: T) => void) => Unsubscribe,
): Behavior<T> {
return scope.behavior(
new Observable<T>((subscriber) =>
subscribe((value) => subscriber.next(value)),
),
get(),
);
}
-77
View File
@@ -1,77 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { KeyProviderEvent } from "livekit-client";
import { testScope } from "../utils/test";
import {
FakeParticipation,
fakeMediaKey,
fakeMembership,
} from "../utils/test-participation";
import { waitFor } from "../driver/MockRtcMatrixDriver";
import { ParticipationKeyProvider } from "./participationKeyProvider";
interface SetKey {
participantIdentity: string | undefined;
keyIndex: number | undefined;
}
function attached(): {
rtcParticipationManager: FakeParticipation;
setKeys: SetKey[];
} {
const rtcParticipationManager = new FakeParticipation();
const provider = new ParticipationKeyProvider();
const setKeys: SetKey[] = [];
provider.on(KeyProviderEvent.SetKey, ({ participantIdentity, keyIndex }) =>
setKeys.push({ participantIdentity, keyIndex }),
);
provider.attach(testScope(), rtcParticipationManager);
return { rtcParticipationManager, setKeys };
}
describe("ParticipationKeyProvider", () => {
it("hands our own key to LiveKit under our transport identity", async () => {
const { rtcParticipationManager, setKeys } = attached();
rtcParticipationManager.ownMemberId$.next("m-me");
rtcParticipationManager.ownTransportIdentity$.next("lk-me");
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-me", index: 0 }),
]);
await waitFor("own key set", () => setKeys.length === 1);
expect(setKeys).toEqual([{ participantIdentity: "lk-me", keyIndex: 0 }]);
});
it("waits for a peer's transport identity and sets each key once", async () => {
const { rtcParticipationManager, setKeys } = attached();
// The key arrives before the roster knows the member's identity.
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-peer", index: 2 }),
]);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(setKeys).toEqual([]);
rtcParticipationManager.setMemberships([
fakeMembership({
member: { memberId: "m-peer" },
transportIdentity: "lk-peer",
}),
]);
await waitFor("peer key set", () => setKeys.length === 1);
expect(setKeys).toEqual([{ participantIdentity: "lk-peer", keyIndex: 2 }]);
// The map is re-emitted (a rotation elsewhere): no second delivery.
rtcParticipationManager.keyMap$.next([
fakeMediaKey({ memberId: "m-peer", index: 2 }),
fakeMediaKey({ memberId: "m-peer", index: 3 }),
]);
await waitFor("next index set", () => setKeys.length === 2);
expect(setKeys[1]).toEqual({ participantIdentity: "lk-peer", keyIndex: 3 });
});
});
-92
View File
@@ -1,92 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BaseKeyProvider } from "livekit-client";
import { combineLatest } from "rxjs";
import { logger as rootLogger, type Logger } from "matrix-js-sdk/lib/logger";
import { type Behavior } from "../state/Behavior";
import { type Epoch, type ObservableScope } from "../state/ObservableScope";
import { type FfiMediaKey, type FfiMembership } from "../matrix-rtc-sdk";
/** What this provider needs from a {@link RtcParticipationManager}. */
export interface ParticipationKeys {
/** Every media key in use, ours and theirs, one per (member, index). */
keyMap$: Behavior<FfiMediaKey[]>;
memberships$: Behavior<Epoch<FfiMembership[]>>;
ownMemberId$: Behavior<string | null>;
ownTransportIdentity$: Behavior<string | null>;
}
/**
* Feeds the crate's media keys to livekit-client's E2EE workers.
*
* The crate exchanges keys per member id; LiveKit encrypts per participant
* identity. A key is handed over as soon as both are known — a key that
* arrives before its member's transport identity waits on the roster rather
* than being dropped — and once per (member, index, identity), so a
* re-emitted map does not churn the key ring.
*/
export class ParticipationKeyProvider extends BaseKeyProvider {
private readonly logger: Logger;
private readonly applied = new Set<string>();
public constructor() {
super({ ratchetWindowSize: 10, keyringSize: 256 });
this.logger = rootLogger.getChild("[ParticipationKeyProvider]");
}
/** Follow the participation's keys for as long as `scope` lives. */
public attach(
scope: ObservableScope,
rtcParticipationManager: ParticipationKeys,
): void {
combineLatest([
rtcParticipationManager.keyMap$,
rtcParticipationManager.memberships$,
rtcParticipationManager.ownMemberId$,
rtcParticipationManager.ownTransportIdentity$,
])
.pipe(scope.bind())
.subscribe(([keys, memberships, ownMemberId, ownIdentity]) => {
for (const key of keys) {
const identity =
key.memberId === ownMemberId
? ownIdentity
: memberships.value.find(
(m) => m.member.memberId === key.memberId,
)?.transportIdentity;
if (!identity) continue;
const tag = `${key.memberId}|${key.index}|${identity}`;
if (this.applied.has(tag)) continue;
this.applied.add(tag);
void this.setKey(key, identity);
}
});
}
private async setKey(key: FfiMediaKey, identity: string): Promise<void> {
try {
const material = await crypto.subtle.importKey(
"raw",
key.key,
"HKDF",
false,
["deriveBits", "deriveKey"],
);
this.onSetEncryptionKey(material, identity, key.index);
this.logger.debug(
`Set key for participant ${identity} (member ${key.memberId}) index ${key.index}`,
);
} catch (e) {
this.logger.error(
`Failed to import the key of member ${key.memberId} index ${key.index}`,
e,
);
}
}
}
+8 -27
View File
@@ -87,16 +87,8 @@ export type SharedSecret = { kind: E2eeType.SHARED_KEY; secret: string };
export type PerParticipantE2EE = { kind: E2eeType.PER_PARTICIPANT };
export type EncryptionSystem = Unencrypted | SharedSecret | PerParticipantE2EE;
/**
* The encryption system for a room whose `m.room.encryption` state the
* caller already knows: a shared secret from the URL or storage wins, else
* per-participant keys in an encrypted room, else nothing. The call tree
* reads `roomEncrypted` from the client driver's room info.
*/
export function useEncryptionSystemFor(
roomId: string,
roomEncrypted: boolean,
): EncryptionSystem {
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
const { roomId: paramsRoomId, password } = useUrlParams();
const [storedPassword] = useRoomSharedKey(
@@ -108,29 +100,18 @@ export function useEncryptionSystemFor(
keyForRoom(roomId, paramsRoomId, password) ?? undefined,
);
return <EncryptionSystem>useMemo(() => {
const room = client?.getRoom(roomId);
const e2eeSystem = <EncryptionSystem>useMemo(() => {
if (!room) return { kind: E2eeType.NONE };
if (storedPassword)
return {
kind: E2eeType.SHARED_KEY,
secret: storedPassword,
};
if (roomEncrypted) {
if (room.hasEncryptionStateEvent()) {
return { kind: E2eeType.PER_PARTICIPANT };
}
return { kind: E2eeType.NONE };
}, [roomEncrypted, storedPassword]);
}
/** {@link useEncryptionSystemFor} over the client's room; nothing for a room it does not know. */
export function useRoomEncryptionSystem(roomId: string): EncryptionSystem {
const { client } = useClient();
const room = client?.getRoom(roomId);
const e2eeSystem = useEncryptionSystemFor(
roomId,
room?.hasEncryptionStateEvent() ?? false,
);
return useMemo(
() => (room ? e2eeSystem : { kind: E2eeType.NONE }),
[room, e2eeSystem],
);
}, [room, storedPassword]);
return e2eeSystem;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

@@ -43,13 +43,40 @@ const wasmFileset: WasmFileset = {
};
/**
* Track processor that applies effects such as blurring to a user's background.
* Track processor that applies background effects — blur, or a replacement
* image — to a user's camera.
*
* This is just like LiveKit's prebuilt BackgroundTransformer except that it
* loads the segmentation models from our own bundle rather than as an external
* resource fetched from the public internet.
* resource fetched from the public internet. The library's own `assetPaths`
* option cannot do this: it takes a directory and resolves the WASM through
* MediaPipe's FilesetResolver, which picks its own variants, whereas we want
* only the SIMD ones.
*/
export class BlurBackgroundTransformer extends BackgroundTransformer {
export class BackgroundEffectTransformer extends BackgroundTransformer {
/**
* Called once, when a frame has actually come out of the pipeline.
*
* The promises say nothing useful about when that happens. Measured on four
* devices, attaching resolves in about three seconds and switching in none
* at all, and then a browser on the slow path spends another twelve to
* fifteen seconds before the first frame appears. Only the frame itself
* marks the end of the wait.
*/
public onFirstFrame: (() => void) | undefined;
private produced = false;
public override async transform(
frame: VideoFrame,
controller: TransformStreamDefaultController<VideoFrame>,
): Promise<void> {
await super.transform(frame, controller);
if (!this.produced) {
this.produced = true;
this.onFirstFrame?.();
}
}
public async init({
outputCanvas,
inputElement: inputVideo,
@@ -73,8 +100,16 @@ export class BlurBackgroundTransformer extends BackgroundTransformer {
outputConfidenceMasks: false,
});
if (this.options.blurRadius) {
// BackgroundTransformer applies these at the end of its own init. Because
// we replace init wholesale rather than extending it, we have to repeat
// them, or an effect that was already selected when the pipeline starts is
// silently ignored.
if (this.options.imagePath) {
await this.loadAndSetBackground(this.options.imagePath);
}
if (typeof this.options.blurRadius === "number") {
this.gl?.setBlurRadius(this.options.blurRadius);
}
this.gl?.setBackgroundDisabled(this.options.backgroundDisabled ?? false);
}
}
+401
View File
@@ -0,0 +1,401 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type FC, useCallback, useRef, useState } from "react";
import {
createLocalVideoTrack,
type LocalVideoTrack,
VideoPresets,
} from "livekit-client";
import {
BackgroundProcessorWrapper,
supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk,
supportsModernBackgroundProcessors,
type SwitchBackgroundProcessorOptions,
} from "@livekit/track-processors";
import { logger } from "matrix-js-sdk/lib/logger";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import { blurRadius, imagePathFor } from "./backgroundEffects";
import { supportsBackgroundProcessors } from "./backgroundProcessing";
import { platform } from "../Platform";
/**
* Whether background effects can hold their frame rate on this device, asked
* without a call in the way.
*
* The camera and the pipeline only: no SFU, no encoder, no second participant.
* If a device cannot hold a frame rate here it cannot hold one in a call
* either, so this is the cheap half of the question — and it is the half that
* has never been asked. The gate that keeps effects off phones was added from
* reports, not measurements, and nothing in the app will run the pipeline
* there, so there has been nothing to measure.
*
* Deliberately ignores that gate: measuring what it forbids is the point. It
* reports what the gate would have said, and runs anyway.
*
* Exploration only, and never ported: an unlisted route, its text not
* translated, its numbers meant for whoever is holding the phone.
*/
type Effect = "none" | "blur" | "image";
interface Bucket {
/** Seconds since the first frame. */
at: number;
fps: number;
}
interface Run {
effect: Effect;
device: string;
platform: string;
/** Whether the browser has the API that avoids the canvas fallback. */
modernApi: boolean;
/** What the app's own verdict would have said about this device. */
appWouldAllow: boolean;
sdkWouldAllow: boolean;
capture: { width?: number; height?: number; frameRate?: number };
processed: { width?: number; height?: number; frameRate?: number };
/** Milliseconds from asking for the effect until the pipeline reports ready. */
startupMs: number | null;
/**
* Where that time went: attaching builds the segmenter — fetching its
* assets, compiling the WASM, setting up the GPU — while switching only
* tells an already-built one what to draw. Split, because the two have
* different answers: one can be paid ahead of time, the other cannot.
*/
attachMs: number | null;
switchMs: number | null;
/**
* The segmenter's own assets, as the browser saw them. A transfer size of
* zero means the bytes came from cache and no network was involved, which is
* what separates "the first time on this device" from "every time".
*/
assets: {
name: string;
transferredBytes: number;
bytes: number;
ms: number;
}[];
meanFps: number;
/** The worst five-second stretch — where throttling shows up first. */
worstFps: number;
buckets: Bucket[];
/** Worst observed delay on a 100ms timer: the main thread's own stutter. */
worstTimerLagMs: number;
/** The longest the picture simply stopped, in milliseconds. */
worstFrameGapMs: number;
/** From asking for the run to the first frame on screen. */
firstFrameMs: number | null;
frames: number;
seconds: number;
}
const bucketSeconds = 5;
function optionsFor(effect: Effect): SwitchBackgroundProcessorOptions {
switch (effect) {
case "blur":
return { mode: "background-blur", blurRadius };
case "image": {
const imagePath = imagePathFor("indoor");
return imagePath
? { mode: "virtual-background", imagePath }
: { mode: "disabled" };
}
default:
return { mode: "disabled" };
}
}
export const BackgroundEffectsBench: FC = () => {
const video = useRef<HTMLVideoElement | null>(null);
const [seconds, setSeconds] = useState(60);
const [busy, setBusy] = useState(false);
const [status, setStatus] = useState("Ready.");
const [runs, setRuns] = useState<Run[]>([]);
const measure = useCallback(
async (effect: Effect): Promise<void> => {
setBusy(true);
setStatus(`Starting the camera for "${effect}"…`);
let track: LocalVideoTrack | undefined;
try {
// The resolution and frame rate a real call captures at, so the cost
// measured here is the cost a call would pay.
track = await createLocalVideoTrack({
resolution: VideoPresets.h720.resolution,
});
const capture = track.mediaStreamTrack.getSettings();
let startupMs: number | null = null;
let attachMs: number | null = null;
let switchMs: number | null = null;
if (effect !== "none") {
setStatus("Starting the pipeline…");
const askedAt = performance.now();
const pipeline = new BackgroundProcessorWrapper(
new BackgroundEffectTransformer({ backgroundDisabled: true }),
"background-effect-bench",
);
await track.setProcessor(pipeline);
const attachedAt = performance.now();
await pipeline.switchTo(optionsFor(effect));
const readyAt = performance.now();
attachMs = Math.round(attachedAt - askedAt);
switchMs = Math.round(readyAt - attachedAt);
startupMs = Math.round(readyAt - askedAt);
}
const element = video.current!;
track.attach(element);
await element.play().catch(() => undefined);
setStatus(`Measuring "${effect}" for ${seconds}s…`);
const result = await countFrames(element, seconds, (done) =>
setStatus(`Measuring "${effect}": ${done}s of ${seconds}s`),
);
const processed = track.mediaStreamTrack.getSettings();
setRuns((previous) => [
...previous,
{
effect,
device: navigator.userAgent,
platform,
modernApi: supportsModernBackgroundProcessors(),
appWouldAllow: supportsBackgroundProcessors(),
sdkWouldAllow: supportsBackgroundProcessorsLivekitSdk(),
capture: {
width: capture.width,
height: capture.height,
frameRate: capture.frameRate,
},
processed: {
width: processed.width,
height: processed.height,
frameRate: processed.frameRate,
},
startupMs,
attachMs,
switchMs,
assets: segmenterAssets(),
...result,
},
]);
setStatus(`Done: ${effect}.`);
} catch (e) {
logger.error("Background effects bench failed", e);
setStatus(`Failed: ${e instanceof Error ? e.message : String(e)}`);
} finally {
track?.stop();
setBusy(false);
}
},
[seconds],
);
return (
<div style={{ padding: 16, font: "14px system-ui", lineHeight: 1.5 }}>
<h1 style={{ fontSize: 20 }}>Background effects: can this device?</h1>
<p>
The camera and the pipeline, with no call in the way. Run{" "}
<strong>No effect</strong> first — every other number is only meaningful
beside it, on this device, in this light.
</p>
<p>
This device: <code>{platform}</code>, the app would{" "}
<strong>{supportsBackgroundProcessors() ? "allow" : "refuse"}</strong>{" "}
effects here, the browser has{" "}
<strong>
{supportsModernBackgroundProcessors()
? "the fast path"
: "only the canvas fallback"}
</strong>
.
</p>
<label>
Seconds per run:{" "}
<select
value={seconds}
onChange={(e) => setSeconds(Number(e.target.value))}
disabled={busy}
>
<option value={30}>30</option>
<option value={60}>60</option>
<option value={300}>300 (five minutes, for throttling)</option>
<option value={600}>600 (ten minutes)</option>
</select>
</label>
<div style={{ display: "flex", gap: 8, margin: "12px 0" }}>
{(["none", "blur", "image"] as const).map((effect) => (
<button
key={effect}
disabled={busy}
onClick={() => void measure(effect)}
style={{ padding: "8px 12px" }}
>
{effect === "none" ? "No effect" : effect}
</button>
))}
</div>
<p>
<strong>{status}</strong>
</p>
<video
ref={video}
muted
playsInline
style={{ inlineSize: "100%", maxInlineSize: 320, background: "#000" }}
/>
{runs.map((run, i) => (
<div key={i} style={{ marginBlockStart: 12 }}>
<strong>{run.effect}</strong>: mean {run.meanFps.toFixed(1)}fps, worst{" "}
{run.worstFps.toFixed(1)}fps
{run.startupMs !== null && (
<>
, ready in {run.startupMs}ms ({run.attachMs}ms attaching,{" "}
{run.switchMs}ms switching)
</>
)}
, first frame at {run.firstFrameMs ?? "?"}ms, picture stopped for up
to {run.worstFrameGapMs}ms, main thread stalled up to{" "}
{run.worstTimerLagMs}ms
</div>
))}
{runs.length > 0 && (
<textarea
readOnly
value={JSON.stringify(runs, null, 2)}
style={{ inlineSize: "100%", blockSize: 240, marginBlockStart: 12 }}
/>
)}
</div>
);
};
/**
* What the browser did to fetch the segmenter: bytes over the wire, bytes
* total, and how long each took. Same-origin, so the sizes are not hidden.
*/
function segmenterAssets(): Run["assets"] {
return performance
.getEntriesByType("resource")
.filter((e): e is PerformanceResourceTiming =>
/\.(wasm|tflite|task|binarypb)(\?|$)/.test(e.name),
)
.map((e) => ({
name: e.name.slice(e.name.lastIndexOf("/") + 1),
transferredBytes: e.transferSize,
bytes: e.encodedBodySize,
ms: Math.round(e.duration),
}));
}
/**
* Counts frames as the video actually presents them, which is the rate the
* pipeline is really delivering rather than the rate it was asked for.
*/
async function countFrames(
element: HTMLVideoElement,
seconds: number,
onProgress: (done: number) => void,
): Promise<
Pick<
Run,
| "meanFps"
| "worstFps"
| "buckets"
| "worstTimerLagMs"
| "worstFrameGapMs"
| "firstFrameMs"
| "frames"
| "seconds"
>
> {
return new Promise((resolve) => {
const buckets: Bucket[] = [];
const startedAt = performance.now();
let frames = 0;
let inBucket = 0;
let firstFrameMs: number | null = null;
let lastFrameAt = startedAt;
let worstFrameGapMs = 0;
// A phone that is struggling stalls its main thread, and a timer is the
// cheapest witness to that: Safari has no long-task observer.
let worstTimerLagMs = 0;
let lastTick = performance.now();
const ticker = window.setInterval(() => {
const now = performance.now();
worstTimerLagMs = Math.max(
worstTimerLagMs,
Math.round(now - lastTick - 100),
);
lastTick = now;
}, 100);
// On the clock, not on the frames. Closing a bucket only when a frame
// arrives cannot report a stretch with no frames in it at all — the first
// measurement on a phone spent fourteen seconds inside one bucket and
// reported it as a seventh of a frame per second rather than as a freeze.
const bucketer = window.setInterval(() => {
const at = Math.round((performance.now() - startedAt) / 1000);
buckets.push({ at, fps: inBucket / bucketSeconds });
inBucket = 0;
onProgress(at);
}, bucketSeconds * 1000);
const finish = (): void => {
window.clearInterval(ticker);
window.clearInterval(bucketer);
const elapsed = (performance.now() - startedAt) / 1000;
resolve({
meanFps: elapsed > 0 ? frames / elapsed : 0,
worstFps: buckets.length ? Math.min(...buckets.map((b) => b.fps)) : 0,
buckets,
worstTimerLagMs,
worstFrameGapMs: Math.round(worstFrameGapMs),
firstFrameMs: firstFrameMs === null ? null : Math.round(firstFrameMs),
frames,
seconds: Math.round(elapsed),
});
};
const onFrame = (): void => {
const now = performance.now();
firstFrameMs ??= now - startedAt;
worstFrameGapMs = Math.max(worstFrameGapMs, now - lastFrameAt);
lastFrameAt = now;
frames += 1;
inBucket += 1;
if (now - startedAt >= seconds * 1000) return finish();
element.requestVideoFrameCallback(onFrame);
};
if (!("requestVideoFrameCallback" in element)) {
window.clearInterval(ticker);
window.clearInterval(bucketer);
resolve({
meanFps: 0,
worstFps: 0,
buckets: [],
worstTimerLagMs: 0,
worstFrameGapMs: 0,
firstFrameMs: null,
frames: 0,
seconds: 0,
});
return;
}
element.requestVideoFrameCallback(onFrame);
// A freeze can outlast the run: without this, a device that never presents
// another frame never finishes and reports nothing at all.
window.setTimeout(finish, seconds * 1000 + 2000);
});
}
+261 -20
View File
@@ -6,41 +6,122 @@ Please see LICENSE in the repository root for full details.
*/
import {
ProcessorWrapper,
supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk,
BackgroundProcessorWrapper,
type ProcessorWrapper,
type BackgroundOptions,
type SwitchBackgroundProcessorOptions,
} from "@livekit/track-processors";
import {
createContext,
type FC,
type JSX,
use,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type LocalVideoTrack } from "livekit-client";
import { logger } from "matrix-js-sdk/lib/logger";
import { combineLatest, map, type Observable } from "rxjs";
import { useObservable } from "observable-hooks";
import { BehaviorSubject, combineLatest, map, type Observable } from "rxjs";
import { useObservable, useObservableEagerState } from "observable-hooks";
import {
backgroundBlur as backgroundBlurSettings,
backgroundEffect as backgroundEffectSetting,
useSetting,
} from "../settings/settings";
import { BlurBackgroundTransformer } from "./BlurBackgroundTransformer";
import { BackgroundEffectTransformer } from "./BackgroundEffectTransformer";
import {
blurRadius,
imagePathFor,
parseEffect,
type BackgroundEffect,
} from "./backgroundEffects";
import { BackgroundImageStore } from "./backgroundImages";
import { supportsBackgroundProcessors } from "./backgroundProcessing";
import { type Behavior } from "../state/Behavior";
import { type ObservableScope } from "../state/ObservableScope";
import { platform } from "../Platform";
//TODO-MULTI-SFU: This is not yet fully there.
// it is a combination of exposing observable and react hooks.
// preferably we should not make this a context anymore and instead just a vm?
/** A background the user added, as the view needs it: an id and a picture. */
export interface AddedBackgroundImage {
id: string;
/** Lives as long as the provider does. */
url: string;
}
/**
* What the publishing side needs: whether effects can run at all, and the
* pipeline to attach. Deliberately not the backgrounds a user has added — the
* publisher has no use for those, and every test that stands in for this would
* have to carry them.
*/
export type ProcessorState = {
supported: boolean | undefined;
processor: undefined | ProcessorWrapper<BackgroundOptions>;
};
/** What the camera menu needs: the backgrounds this device keeps. */
export interface AddedBackgrounds {
/** Oldest first. */
added: AddedBackgroundImage[];
/**
* Keeps a file as a background. Rejects with `UnusableImage` for a file that
* cannot be used, and `RangeError` once the device keeps as many as it will.
*/
addBackground: (file: Blob) => Promise<string>;
removeBackground: (id: string) => Promise<void>;
}
/** What the camera menu needs to know about the pipeline itself. */
export interface BackgroundProcessing {
/**
* Whether the pipeline is still being built. Only ever true once a session,
* and only the first time an effect is chosen: building it fetches, compiles
* and sets up the segmenter, which on some browsers holds the page still for
* long enough that saying nothing looks like a crash.
*/
settling: boolean;
/**
* The camera track the pipeline is synced to: the pre-join preview's before
* joining, the published one after, and none while the camera is off. It
* draws with whatever effect is in force, and plainly when none has ever
* been chosen, so the menu can show someone how they look without building
* anything of its own.
*/
cameraTrack: LocalVideoTrack | null;
}
const BackgroundProcessingContext = createContext<
BackgroundProcessing | undefined
>(undefined);
export function useBackgroundProcessing(): BackgroundProcessing {
const value = use(BackgroundProcessingContext);
if (value === undefined)
throw new Error(
"useBackgroundProcessing must be used within a ProcessorProvider",
);
return value;
}
const AddedBackgroundsContext = createContext<AddedBackgrounds | undefined>(
undefined,
);
export function useAddedBackgrounds(): AddedBackgrounds {
const value = use(AddedBackgroundsContext);
if (value === undefined)
throw new Error(
"useAddedBackgrounds must be used within a ProcessorProvider",
);
return value;
}
const ProcessorContext = createContext<ProcessorState | undefined>(undefined);
export function useTrackProcessor(): ProcessorState {
@@ -97,6 +178,27 @@ export function applyProcessor(
/**
* Updates your video tracks to always use the given processor.
*/
/**
* The camera track the pipeline was last synced to.
*
* Module state rather than context, because one of the two paths that sync a
* track is not React at all: in a call the publisher does it, from an
* observable. There is one camera and one pipeline for the app's lifetime, so
* there is one track to know about; each path reports its own, and clears it
* only if it is still the one reported, so a lobby leaving cannot blank a call
* that has just started.
*/
const syncedCameraTrack$ = new BehaviorSubject<LocalVideoTrack | null>(null);
function reportCameraTrack(track: LocalVideoTrack | null): void {
if (syncedCameraTrack$.value !== track) syncedCameraTrack$.next(track);
}
function withdrawCameraTrack(track: LocalVideoTrack | null): void {
if (track !== null && syncedCameraTrack$.value === track)
syncedCameraTrack$.next(null);
}
export const trackProcessorSync = (
scope: ObservableScope,
videoTrack$: Behavior<LocalVideoTrack | null>,
@@ -109,6 +211,15 @@ export const trackProcessorSync = (
if (!videoTrack) return;
applyProcessor(videoTrack, processorState.processor);
});
let reported: LocalVideoTrack | null = null;
videoTrack$.pipe(scope.bind()).subscribe({
next: (videoTrack) => {
withdrawCameraTrack(reported);
reported = videoTrack;
reportCameraTrack(videoTrack);
},
complete: () => withdrawCameraTrack(reported),
});
};
export const useTrackProcessorSync = (
@@ -119,37 +230,167 @@ export const useTrackProcessorSync = (
if (!videoTrack) return;
applyProcessor(videoTrack, processor);
}, [processor, videoTrack]);
useEffect(() => {
reportCameraTrack(videoTrack);
return (): void => withdrawCameraTrack(videoTrack);
}, [videoTrack]);
};
interface Props {
children: JSX.Element;
}
function supportsBackgroundProcessors(): boolean {
return supportsBackgroundProcessorsLivekitSdk() && platform === "desktop";
/** Translates a chosen effect into the pipeline's own vocabulary. */
function switchOptionsFor(
effect: BackgroundEffect,
added: AddedBackgroundImage[],
): SwitchBackgroundProcessorOptions {
const withImage = (
imagePath: string | undefined,
): SwitchBackgroundProcessorOptions =>
// A background the device no longer has — removed, or storage cleared —
// leaves the user with no effect rather than a pipeline drawing nothing.
imagePath
? { mode: "virtual-background", imagePath }
: { mode: "disabled" };
switch (effect.kind) {
case "blur":
return { mode: "background-blur", blurRadius };
case "shipped":
return withImage(imagePathFor(effect.id));
case "added":
return withImage(added.find((a) => a.id === effect.id)?.url);
default:
return { mode: "disabled" };
}
}
export const ProcessorProvider: FC<Props> = ({ children }) => {
// The setting the user wants to have
const [blurActivated] = useSetting(backgroundBlurSettings);
const [effectRaw] = useSetting(backgroundEffectSetting);
const supported = useMemo(() => supportsBackgroundProcessors(), []);
const blur = useMemo(
() =>
new ProcessorWrapper(
new BlurBackgroundTransformer({ blurRadius: 15 }),
"background-blur",
),
// One pipeline for the lifetime of the app, so the pre-join preview and the
// call share it and its priming frame is spent before anything is published
// (D4).
const transformer = useMemo(
() => new BackgroundEffectTransformer({ backgroundDisabled: true }),
[],
);
const pipeline = useMemo(
() => new BackgroundProcessorWrapper(transformer, "background-effect"),
[transformer],
);
// The backgrounds this device keeps. Their URLs live as long as the provider
// does, and are replaced wholesale whenever the set changes: an object URL
// outlives the blob it names unless it is revoked, and the alternative is
// tracking one lifetime per image.
const store = useMemo(() => new BackgroundImageStore(), []);
const [added, setAdded] = useState<AddedBackgroundImage[]>([]);
const urls = useRef<string[]>([]);
const reread = useCallback(async (): Promise<void> => {
const kept = await store.list();
urls.current.forEach((url) => URL.revokeObjectURL(url));
urls.current = kept.map((background) =>
URL.createObjectURL(background.image),
);
setAdded(kept.map(({ id }, i) => ({ id, url: urls.current[i] })));
}, [store]);
useEffect(() => {
reread().catch((e) => logger.warn("Could not read added backgrounds", e));
const opened = urls;
return (): void => {
opened.current.forEach((url) => URL.revokeObjectURL(url));
opened.current = [];
};
}, [reread]);
const addBackground = useCallback(
async (file: Blob): Promise<string> => {
const kept = await store.add(file);
await reread();
return kept.id;
},
[store, reread],
);
const removeBackground = useCallback(
async (id: string): Promise<void> => {
await store.remove(id);
await reread();
},
[store, reread],
);
// D5: nothing is attached until an effect is first chosen, so a user who
// never chooses one pays neither the segmentation assets nor the time to
// initialise them. Once attached it stays attached, including at no effect,
// so a later change is a switch rather than a fresh attachment and spends no
// further priming frame.
const [attached, setAttached] = useState(
() => parseEffect(effectRaw).kind !== "none",
);
useEffect(() => {
if (parseEffect(effectRaw).kind !== "none") setAttached(true);
}, [effectRaw]);
// D2: switch the running pipeline in place rather than tearing it down, so
// the previous effect stays in force until the new one is live.
const options = useMemo(
() => switchOptionsFor(parseEffect(effectRaw), added),
[effectRaw, added],
);
useEffect(() => {
if (!supported || !attached) return;
pipeline
.switchTo(options)
.catch((e) => logger.warn("Failed to switch background effect", e));
}, [pipeline, supported, attached, options]);
// The wait runs from deciding to attach until a frame actually comes out,
// which is the only thing that marks the end of it: the promises resolve
// while the segmenter is still being built, and on the slow path the page
// then holds still for another twelve to fifteen seconds. Bounded by the
// frame rather than by a timer, so it cannot end early and claim to be ready.
const [producedAFrame, setProducedAFrame] = useState(false);
useEffect(() => {
transformer.onFirstFrame = (): void => setProducedAFrame(true);
return (): void => {
transformer.onFirstFrame = undefined;
};
}, [transformer]);
const settling = supported === true && attached && !producedAFrame;
// This is the actual state exposed through the context
const processorState = useMemo(
() => ({
supported,
processor: supported && blurActivated ? blur : undefined,
processor: supported && attached ? pipeline : undefined,
}),
[supported, blurActivated, blur],
[supported, attached, pipeline],
);
return <ProcessorContext value={processorState}>{children}</ProcessorContext>;
const addedBackgrounds = useMemo(
() => ({ added, addBackground, removeBackground }),
[added, addBackground, removeBackground],
);
const cameraTrack = useObservableEagerState(syncedCameraTrack$);
const backgroundProcessing = useMemo(
() => ({ settling, cameraTrack }),
[settling, cameraTrack],
);
return (
<ProcessorContext value={processorState}>
<AddedBackgroundsContext value={addedBackgrounds}>
<BackgroundProcessingContext value={backgroundProcessing}>
{children}
</BackgroundProcessingContext>
</AddedBackgroundsContext>
</ProcessorContext>
);
};
+92
View File
@@ -0,0 +1,92 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { maxAddedBackgrounds } from "./backgroundImages";
import { describe, expect, test } from "vitest";
import {
parseEffect,
serializeEffect,
shippedBackgrounds,
imagePathFor,
} from "./backgroundEffects";
describe("the chosen background effect", () => {
test("round-trips through its stored form", () => {
for (const effect of [
{ kind: "none" } as const,
{ kind: "blur" } as const,
{ kind: "shipped", id: shippedBackgrounds[0].id } as const,
{ kind: "added", id: "a-uuid" } as const,
])
expect(parseEffect(serializeEffect(effect))).toEqual(effect);
});
test("falls back to no effect when the stored form names nothing we ship", () => {
// A background removed between releases, or a value from a future one.
expect(parseEffect("image:a-background-we-no-longer-ship")).toEqual({
kind: "none",
});
expect(parseEffect("")).toEqual({ kind: "none" });
});
// The regression this guards is not hypothetical: the first stand-ins were
// the app's own overlay gradients, which have no opaque pixel in them at all,
// so as backgrounds their dark half was simply missing.
test("ships only opaque backgrounds", async () => {
const { readFile } = await import("node:fs/promises");
for (const background of shippedBackgrounds) {
const name = background.imagePath.split("/").pop()!.split("?")[0];
const bytes = await readFile(`src/graphics/${name}`);
// JPEG, which has no alpha channel to carry a hole in.
expect([bytes[0], bytes[1], bytes[2]]).toEqual([0xff, 0xd8, 0xff]);
}
});
// Three to a row, and the add tile goes at the limit. At four of their own
// the grid stopped at eight and left its last corner empty; the rule is that
// it is whole both at the limit and one short of it, with the add tile.
test("fills the grid at the limit and one short of it", () => {
const fixed = 2 + shippedBackgrounds.length; // no effect, blur, shipped
expect((fixed + maxAddedBackgrounds) % 3).toBe(0);
expect((fixed + maxAddedBackgrounds - 1 + 1) % 3).toBe(0);
});
test("gives every shipped background an image to draw", () => {
for (const background of shippedBackgrounds)
expect(imagePathFor(background.id)).toBeTruthy();
});
});
describe("the blur control in settings", () => {
// The control is a checkbox over a setting that holds more than two states,
// so what it shows and what it writes are both derived. This is that
// derivation, which is what keeps it from disagreeing with the camera menu.
const shows = (stored: string): boolean =>
parseEffect(stored).kind === "blur";
const writes = (on: boolean): string =>
serializeEffect(on ? { kind: "blur" } : { kind: "none" });
test("reads as off while an image background is in force", () => {
expect(shows(serializeEffect({ kind: "shipped", id: "indoor" }))).toBe(
false,
);
expect(shows(serializeEffect({ kind: "added", id: "a-uuid" }))).toBe(false);
});
test("reads as on while blur is in force", () => {
expect(shows("blur")).toBe(true);
});
test("replaces an image background when it is turned on", () => {
expect(writes(true)).toBe("blur");
});
test("leaves no effect behind when it is turned off", () => {
expect(writes(false)).toBe("none");
});
});
+82
View File
@@ -0,0 +1,82 @@
/*
Copyright 2024-2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import indoorStandIn from "../graphics/background-indoor-standin.jpg?url";
import outdoorStandIn from "../graphics/background-outdoor-standin.jpg?url";
/** How much to blur, when the chosen effect is blur. */
export const blurRadius = 15;
export interface ShippedBackground {
id: string;
imagePath: string;
}
// EXPLORATION SHORTCUT (S1): the two shipped backgrounds, one indoor and one
// outdoor, do not exist yet. These stand-ins are the app's own gradients, laid
// on the canvas colour they are painted over and kept as JPEG.
//
// Not a detail: those gradients are overlay scrims, and measured, not one
// pixel in either is opaque — where they look dark they are transparent. Used
// directly as a background their dark half was simply absent, which is what
// showed on the slow path. Flattening them is what makes them a picture rather
// than a veil, and JPEG cannot carry transparency at all, so it cannot come
// back. Never port this.
export const shippedBackgrounds: ShippedBackground[] = [
{ id: "indoor", imagePath: indoorStandIn },
{ id: "outdoor", imagePath: outdoorStandIn },
];
export type BackgroundEffect =
| { kind: "none" }
| { kind: "blur" }
/** One of the images that come with the application. */
| { kind: "shipped"; id: string }
/** One the user added, kept on this device. */
| { kind: "added"; id: string };
export const noEffect: BackgroundEffect = { kind: "none" };
/**
* Parses the stored form of a chosen effect, falling back to no effect.
*
* A shipped background is checked against what we ship, so a stored one that a
* later release no longer carries falls back rather than leaving the user with
* an effect that cannot be drawn. An added one cannot be checked here — only
* the device knows what it keeps — so that check belongs where it is resolved.
*/
export function parseEffect(raw: string): BackgroundEffect {
if (raw === "blur") return { kind: "blur" };
if (raw.startsWith("image:")) {
const id = raw.slice("image:".length);
if (shippedBackgrounds.some((b) => b.id === id))
return { kind: "shipped", id };
}
if (raw.startsWith("added:")) {
const id = raw.slice("added:".length);
if (id) return { kind: "added", id };
}
return noEffect;
}
/** The stored form of a chosen effect. */
export function serializeEffect(effect: BackgroundEffect): string {
switch (effect.kind) {
case "blur":
return "blur";
case "shipped":
return `image:${effect.id}`;
case "added":
return `added:${effect.id}`;
default:
return "none";
}
}
export function imagePathFor(id: string): string | undefined {
return shippedBackgrounds.find((b) => b.id === id)?.imagePath;
}
+187
View File
@@ -0,0 +1,187 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { logger } from "matrix-js-sdk/lib/logger";
/**
* The most backgrounds of their own a device keeps.
*
* Five, because it fills the grid. With no effect, blur and the two shipped
* images that makes nine tiles at the limit, where the add tile goes; and one
* short of it, four and the add tile make nine too. At four the grid stopped
* at eight and left its last corner empty.
*/
export const maxAddedBackgrounds = 5;
/**
* The longest edge an added background is kept at.
*
* Not a display constraint: the pipeline crops and scales the image to the
* camera on every use, so a larger one buys nothing. It is what stops a
* forty-megapixel photograph sitting in storage and being decoded on every
* call.
*/
export const maxStoredEdge = 1920;
/** A background the user added, as it is kept on the device. */
export interface AddedBackground {
id: string;
image: Blob;
addedAt: number;
}
/** Why a file cannot be used as a background. */
export type UnusableReason = "not-an-image" | "animated" | "undecodable";
export class UnusableImage extends Error {
public constructor(public readonly reason: UnusableReason) {
super(reason);
this.name = "UnusableImage";
}
}
/**
* Whether the file holds more than one frame.
*
* Read from the file rather than guessed from its type: an animated WebP and a
* still one are both `image/webp`, and a `.png` may be an APNG. Where the
* browser cannot tell us, the types that are usually animated are refused
* rather than accepted and left to move behind someone's head.
*/
async function isAnimated(file: Blob): Promise<boolean> {
const decoder = (
globalThis as { ImageDecoder?: new (init: unknown) => unknown }
).ImageDecoder;
if (!decoder) return /gif|apng/.test(file.type);
try {
const d = new decoder({ data: await file.arrayBuffer(), type: file.type });
await (d as { completed: Promise<void> }).completed;
const track = (d as { tracks: { selectedTrack?: { frameCount: number } } })
.tracks.selectedTrack;
return (track?.frameCount ?? 1) > 1;
} catch (e) {
logger.debug("Could not read frame count, judging by type", e);
return /gif|apng/.test(file.type);
}
}
/**
* Prepares a file to be kept as a background: refuses what cannot be used, and
* reduces what is larger than we keep.
*
* Does not crop. The pipeline covers the camera's frame with whatever it is
* given, recomputed as the frame changes, so cropping here would bake in one
* shape and lose the rest of the picture for good.
*/
export async function prepareImage(file: Blob): Promise<Blob> {
if (!file.type.startsWith("image/")) throw new UnusableImage("not-an-image");
if (await isAnimated(file)) throw new UnusableImage("animated");
let bitmap: ImageBitmap;
try {
bitmap = await createImageBitmap(file);
} catch (e) {
logger.debug("Could not decode the chosen file", e);
throw new UnusableImage("undecodable");
}
try {
// Every picture goes through the canvas, not only the oversized ones: a
// background covers what is behind it, so it has to be opaque, and a
// picture with transparency in it — a logo, a screenshot with rounded
// corners — would otherwise be stored with its holes and drawn with them.
// Passing small files straight through is what kept them.
const longest = Math.max(bitmap.width, bitmap.height);
const scale = Math.min(1, maxStoredEdge / longest);
const canvas = new OffscreenCanvas(
Math.round(bitmap.width * scale),
Math.round(bitmap.height * scale),
);
const context = canvas.getContext("2d");
if (!context) throw new UnusableImage("undecodable");
// The ground the picture is laid on, so nothing it does not cover is a
// hole. Black rather than white: an unfilled corner reads as the frame's
// own edge rather than as a lamp.
context.fillStyle = "black";
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
return await canvas.convertToBlob({ type: "image/webp", quality: 0.9 });
} finally {
bitmap.close();
}
}
const DB_NAME = "element-call-background-images";
const STORE = "backgrounds";
/**
* The backgrounds a user added, kept on this device.
*
* They never leave it: there is no upload here and no identifier that would
* let anything fetch one.
*/
export class BackgroundImageStore {
private db?: Promise<IDBDatabase>;
/** The factory is injected so a test can hand over its own. */
public constructor(
private readonly indexedDB: IDBFactory = globalThis.indexedDB,
) {}
private async open(): Promise<IDBDatabase> {
return (this.db ??= new Promise((resolve, reject) => {
const request = this.indexedDB.open(DB_NAME, 1);
request.onupgradeneeded = (): void => {
request.result.createObjectStore(STORE, { keyPath: "id" });
};
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
}));
}
private async run<T>(
mode: IDBTransactionMode,
body: (store: IDBObjectStore) => IDBRequest<T>,
): Promise<T> {
const db = await this.open();
return new Promise((resolve, reject) => {
const request = body(db.transaction(STORE, mode).objectStore(STORE));
request.onsuccess = (): void => resolve(request.result);
request.onerror = (): void => reject(request.error);
});
}
public async list(): Promise<AddedBackground[]> {
const all = await this.run<AddedBackground[]>("readonly", (s) =>
s.getAll(),
);
return all.sort((a, b) => a.addedAt - b.addedAt);
}
/**
* Keeps a file as a background, and answers with it.
*
* Throws {@link UnusableImage} for a file that cannot be used, and
* {@link RangeError} once the device is already keeping as many as it will.
*/
public async add(file: Blob): Promise<AddedBackground> {
const image = await prepareImage(file);
if ((await this.list()).length >= maxAddedBackgrounds)
throw new RangeError("Already keeping the most backgrounds we keep");
const background: AddedBackground = {
id: crypto.randomUUID(),
image,
addedAt: Date.now(),
};
await this.run("readwrite", (s) => s.add(background));
return background;
}
public async remove(id: string): Promise<void> {
await this.run("readwrite", (s) => s.delete(id));
}
}
+54
View File
@@ -0,0 +1,54 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import {
supportsBackgroundProcessors as supportsBackgroundProcessorsLivekitSdk,
supportsModernBackgroundProcessors,
} from "@livekit/track-processors";
/**
* Whether this device can run background effects at all.
*
* The one answer, for everything that asks. The controls and the pipeline used
* to work it out separately, and differently: the pipeline required a desktop
* while the controls asked only whether the browser had the APIs. A phone
* browser has them, so it was offered a choice the pipeline then refused to
* honour, and the video simply never changed — the worst of both, since it
* neither worked nor said why.
*
* Anything that offers a background effect must ask this, so that what is
* offered and what can be delivered cannot drift apart again.
*
* It no longer asks for a desktop. Measured, that test sorted devices by the
* wrong thing: a phone on the fast path held 99% of its frame rate and was
* refused, while a desktop on the slow one held 73% and was allowed. What the
* cost tracks is the path, which {@link usesFallbackProcessing} names — and a
* cost is something to tell someone about, not to decide for them, because the
* user is the only one who knows whether they would rather show the room.
*/
export function supportsBackgroundProcessors(): boolean {
return supportsBackgroundProcessorsLivekitSdk();
}
/**
* Whether effects here run the slow way.
*
* Browsers without `MediaStreamTrackProcessor` — Safari and Firefox, on every
* platform — fall back to drawing each frame through a canvas. Measured across
* four of them, that costs between a sixth and a quarter of the frame rate,
* phone or desktop, and on Safari it also blocks the page for twelve to
* fifteen seconds the first time the segmenter is built.
*
* Not a reason to withhold the feature: that cost ships today wherever the
* fallback runs, and it is a cost in smoothness while the feature is about
* privacy. It is a reason to say so.
*/
export function usesFallbackProcessing(): boolean {
return (
supportsBackgroundProcessors() && !supportsModernBackgroundProcessors()
);
}
-199
View File
@@ -1,199 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { beforeAll, describe, expect, it, onTestFinished, vi } from "vitest";
import { type Logger } from "matrix-js-sdk/lib/logger";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import { installMatrixRtcLogSink, matrixRtcLogSink } from "./logSink";
import {
FfiLogLevel,
setLogSink,
FfiElementCallCompat,
FfiMatrixDriver,
FfiParticipationManager,
FfiStatus,
computeSessionsFromEvents,
type FfiLivekitToken,
type FfiLivekitTokenRequest,
type FfiRtcTransport,
type FfiSendEventResponse,
type FfiToDeviceDelivery,
type FfiToDeviceRecipient,
type MatrixDriverCallback,
} from ".";
const ROOM_ID = "!room:example.org";
describe("matrix-rtc-sdk", () => {
beforeAll(async () => {
await initMatrixRtcSdkForTests();
});
it("computes a session from raw events without a manager", () => {
const join = JSON.stringify({
type: "m.rtc.member",
sender: "@alice:example.org",
event_id: "$1",
room_id: ROOM_ID,
origin_server_ts: Date.now(),
msc4354_sticky: { duration_ms: 240_000 },
content: {
slot_id: "m.call#ROOM",
msc4354_sticky_key: "m-1",
member: { id: "m-1", membership: "join" },
application: { type: "m.call" },
transports: {
published: [
{ type: "livekit", livekit_service_url: "https://lk.example.org" },
],
can_subscribe: ["livekit"],
},
},
});
const [session] = computeSessionsFromEvents(
[join],
FfiElementCallCompat.Off,
);
expect(session.memberCount).toBe(1);
expect(session.members[0].eventId).toBe("$1");
expect(session.isActive).toBe(true);
});
it("constructs a manager over a TypeScript driver and starts disconnected", () => {
const driver = new FfiMatrixDriver(new InertDriver());
const manager = new FfiParticipationManager(
ROOM_ID,
"m.call#ROOM",
"@me:example.org",
"MYDEV",
driver,
{
compat: FfiElementCallCompat.Off,
manageMediaKeys: false,
requireCrossSignedSender: false,
useKeyDelayMs: 1000n,
},
);
expect(FfiStatus.Disconnected.instanceOf(manager.status())).toBe(true);
expect(manager.memberships()).toEqual([]);
expect(manager.ownTransportIdentity()).toBeUndefined();
manager.uniffiDestroy();
});
it("routes the crate's log lines to the installed sink", () => {
const lines: { level: FfiLogLevel; target: string; message: string }[] = [];
setLogSink(
{
log: (level, target, message) => {
lines.push({ level, target, message });
},
},
FfiLogLevel.Debug,
);
// Put the default sink back for the other suites.
onTestFinished(() => installMatrixRtcLogSink());
const driver = new FfiMatrixDriver(new InertDriver());
const manager = new FfiParticipationManager(
ROOM_ID,
"m.call#ROOM",
"@me:example.org",
"MYDEV",
driver,
{
compat: FfiElementCallCompat.Off,
manageMediaKeys: false,
requireCrossSignedSender: false,
useKeyDelayMs: 1000n,
},
);
manager.uniffiDestroy();
const created = lines.find((l) => l.message.includes("session created"));
expect(created).toBeDefined();
expect(created?.level).toBe(FfiLogLevel.Info);
expect(created?.target).toMatch(/^matrix_rtc::/);
});
it("maps the crate's levels onto a matrix-js-sdk logger", () => {
const target = {
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
};
const sink = matrixRtcLogSink(target as unknown as Logger);
sink.log(FfiLogLevel.Error, "matrix_rtc::a", "boom");
sink.log(FfiLogLevel.Warn, "matrix_rtc::a", "hm");
sink.log(FfiLogLevel.Info, "matrix_rtc::a", "fyi");
sink.log(FfiLogLevel.Debug, "matrix_rtc::a", "dbg");
sink.log(FfiLogLevel.Trace, "matrix_rtc::a", "trc");
expect(target.error).toHaveBeenCalledWith("[matrix_rtc::a] boom");
expect(target.warn).toHaveBeenCalledWith("[matrix_rtc::a] hm");
expect(target.info).toHaveBeenCalledWith("[matrix_rtc::a] fyi");
expect(target.debug).toHaveBeenCalledWith("[matrix_rtc::a] dbg");
expect(target.trace).toHaveBeenCalledWith("[matrix_rtc::a] trc");
});
});
/** A driver that answers every read with nothing and never sends. */
class InertDriver implements MatrixDriverCallback {
public async sendStickyEvent(): Promise<FfiSendEventResponse> {
return Promise.resolve({ eventId: "$sticky", delayId: undefined });
}
public async sendStateEvent(): Promise<FfiSendEventResponse> {
return Promise.resolve({ eventId: "$state", delayId: undefined });
}
public async sendDelayedEvent(): Promise<string> {
return Promise.resolve("delay");
}
public async sendDelayedStateEvent(): Promise<string> {
return Promise.resolve("delay");
}
public async restartDelayedEvent(): Promise<void> {
return Promise.resolve();
}
public async cancelDelayedEvent(): Promise<void> {
return Promise.resolve();
}
public async delegateDelayedLeaveViaHomeserver(): Promise<void> {
return Promise.resolve();
}
public async delegateDelayedLeaveViaTransport(): Promise<void> {
return Promise.resolve();
}
public async sendToDevice(
recipients: FfiToDeviceRecipient[],
): Promise<FfiToDeviceDelivery[]> {
return Promise.resolve(
recipients.map((recipient) => ({ recipient, error: undefined })),
);
}
public async getRtcTransports(): Promise<FfiRtcTransport[]> {
return Promise.resolve([]);
}
public async getLivekitToken(
request: FfiLivekitTokenRequest,
): Promise<FfiLivekitToken> {
return Promise.resolve({ jwt: "jwt", url: request.url });
}
public async readEvents(): Promise<string[]> {
return Promise.resolve([]);
}
public async readState(): Promise<string[]> {
return Promise.resolve([]);
}
public subscribeRoomEvents(): void {}
public subscribeToDeviceEvents(): void {}
public subscribeStateUpdates(): void {}
public subscribeConnectivity(): void {}
public isHomeserverConnected(): boolean {
return true;
}
}
-112
View File
@@ -1,112 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* The MatrixRTC SDK: the Rust `matrix-rtc` crate through its uniffi web
* bindings, shipped as the `@element-hq/matrix-rtc` npm package. Everything
* Element Call needs to *participate* in a MatrixRTC session — the session
* projection, our own membership with its keep-alive, transport tokens and
* the media key exchange — lives in the crate; this module loads it and
* re-exports the surface Element Call uses.
*
* Nothing else imports the package directly: the boot below is the one place
* the wasm is loaded and the crate's logging is wired up, so a caller cannot
* end up with a silent or half-initialised SDK.
*/
import { initAsync, type WasmSource } from "@element-hq/matrix-rtc";
import { installMatrixRtcLogSink } from "./logSink";
export {
FfiMatrixDriver,
FfiParticipationManager,
FfiElementCallCompat,
FfiEventOrigin,
FfiStatus,
FfiDelegationRoute,
FfiDeviceAttribution,
FfiDisconnectCause,
FfiImpairment,
FfiJoinError,
FfiKeepAlive,
FfiLogLevel,
setLogSink,
FfiMembershipState,
FfiTransportIntent,
RtcError,
computeSessionsFromEvents,
} from "@element-hq/matrix-rtc";
export type {
FfiConnectionData,
FfiConnectionWithMembers,
FfiHomeserverDelegationRequest,
FfiJoinParams,
FfiLivekitToken,
FfiLivekitTokenRequest,
FfiMediaKey,
FfiMember,
FfiMembership,
FfiParticipationConfig,
FfiRtcTransport,
FfiSendEventResponse,
FfiSessionSnapshot,
FfiToDeviceDelivery,
FfiToDeviceRecipient,
FfiTransportDelegationRequest,
ConnectivitySinkLike,
LogSink,
MatrixDriverCallback,
RoomEventSinkLike,
StateUpdateSinkLike,
ToDeviceSinkLike,
} from "@element-hq/matrix-rtc";
/**
* Where to load the wasm from. A URL (or anything `fetch` accepts) in a
* browser; bytes or a compiled module where there is nothing to fetch from,
* such as tests.
*/
export type MatrixRtcWasmSource = WasmSource;
let loading: Promise<void> | null = null;
/**
* Loads and initialises the SDK. Idempotent: the first call decides the
* source, later calls await the same load.
*
* Without a `source`, the wasm is the package's own copy as the bundler
* placed it (an asset URL in the app builds). A host that serves the file
* from somewhere else, or a test runner with no server, passes its own.
*/
export async function initMatrixRtcSdk(
source?: MatrixRtcWasmSource,
): Promise<void> {
loading ??= (async (): Promise<void> => {
await initAsync(source ?? (await bundledWasm()));
// The crate is silent until told where to log.
installMatrixRtcLogSink();
})();
await loading;
}
/**
* The wasm as the bundler placed it, resolved lazily so nothing is fetched
* until needed. Asked for explicitly, rather than leaving the package to its
* `import.meta.url` default, so that the dev server's dependency
* pre-bundling, which rewrites module URLs, cannot lose track of it.
*
* `no-inline` because the library build would otherwise embed the wasm as a
* base64 `data:` URL: a megabyte in the JavaScript, and one a host's Content
* Security Policy may well refuse to `fetch` (Element Web's does). As a file
* next to the bundle, referenced through `import.meta.url`, the host's own
* bundler copies it along with everything else.
*/
async function bundledWasm(): Promise<string> {
const { default: url } =
await import("@element-hq/matrix-rtc/wasm?url&no-inline");
return url;
}
-55
View File
@@ -1,55 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type Logger, logger as rootLogger } from "matrix-js-sdk/lib/logger";
import { FfiLogLevel, type LogSink, setLogSink } from "@element-hq/matrix-rtc";
/**
* A {@link LogSink} that writes the crate's lines to a matrix-js-sdk logger,
* so they land in the same console and rageshake as everything else, under
* the logger's own level filtering. Each line names the Rust module it came
* from.
*/
export function matrixRtcLogSink(target: Logger): LogSink {
return {
log(level, module, message): void {
const line = `[${module}] ${message}`;
switch (level) {
case FfiLogLevel.Error:
target.error(line);
break;
case FfiLogLevel.Warn:
target.warn(line);
break;
case FfiLogLevel.Info:
target.info(line);
break;
case FfiLogLevel.Debug:
target.debug(line);
break;
case FfiLogLevel.Trace:
target.trace(line);
break;
}
},
};
}
/**
* Routes the crate's log lines to `[matrix-rtc]` under Element Call's root
* logger. `maxLevel` is the crate-side cut-off: what it does not format it
* does not send, so trace stays off unless asked for.
*/
export function installMatrixRtcLogSink(
maxLevel: FfiLogLevel = FfiLogLevel.Debug,
): void {
setLogSink(
matrixRtcLogSink(rootLogger.getChild("[matrix-rust-rtc]")),
maxLevel,
);
}
-33
View File
@@ -1,33 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { useEffect, useState } from "react";
import {
type OwnProfile,
type ProfileDriver,
} from "../driver/ElementCallMatrixClientDriver";
import { useMatrixDrivers } from "../driver/MatrixDriverContext";
/** The user's own display name and avatar from a profile driver, kept current. */
export function useOwnProfileFrom(profile: ProfileDriver): OwnProfile {
const [own, setOwn] = useState(() => profile.getOwnProfile());
useEffect(() => {
setOwn(profile.getOwnProfile());
return profile.subscribeOwnProfile(setOwn);
}, [profile]);
return own;
}
/**
* {@link useOwnProfileFrom} over the client driver the host provided. The
* read-only counterpart of `useProfile(client)`, for the call tree; editing
* stays with the profile settings, which a host may not offer.
*/
export function useOwnProfile(): OwnProfile {
return useOwnProfileFrom(useMatrixDrivers().clientDriver);
}
@@ -1,158 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { describe, expect, it } from "vitest";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { testScope } from "../utils/test";
import { FakeParticipation, fakeMembership } from "../utils/test-participation";
import { ElementCallReactionEventType, type RaisedHandInfo } from ".";
import { ParticipationReactionsReader } from "./ParticipationReactionsReader";
const alice = fakeMembership({
member: {
memberId: "m-alice",
userId: "@alice:example.org",
deviceId: "ALICE",
eventId: "$alice-join",
},
});
const aliceId = "@alice:example.org:ALICE";
function raisedHand(
timeline: MockElementCallMatrixClientDriver,
member = alice,
eventId = "$hand",
): void {
timeline.emitTimelineEvent({
eventId,
type: "m.reaction",
sender: member.member.userId,
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: member.member.eventId,
key: "🖐️",
},
},
originServerTs: 1000,
});
}
function setUp(): {
rtcParticipationManager: FakeParticipation;
timeline: MockElementCallMatrixClientDriver;
hands: () => Record<string, RaisedHandInfo>;
reactions: () => string[];
} {
const rtcParticipationManager = new FakeParticipation();
const timeline = new MockElementCallMatrixClientDriver();
const reader = new ParticipationReactionsReader(
testScope(),
rtcParticipationManager,
timeline,
);
let hands: Record<string, RaisedHandInfo> = {};
reader.raisedHands$.subscribe((h) => (hands = h));
let reactions: string[] = [];
reader.reactions$.subscribe(
(r) => (reactions = Object.values(r).map((v) => v.reactionOption.emoji)),
);
return {
rtcParticipationManager,
timeline,
hands: () => hands,
reactions: () => reactions,
};
}
describe("ParticipationReactionsReader", () => {
it("raises and lowers a hand with the member's reaction and its redaction", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline);
expect(hands()).toEqual({
[aliceId]: {
membershipEventId: "$alice-join",
reactionEventId: "$hand",
time: new Date(1000),
},
});
timeline.emitTimelineEvent({
eventId: "$redaction",
type: "m.room.redaction",
sender: alice.member.userId,
content: {},
originServerTs: 2000,
redacts: "$hand",
});
expect(hands()).toEqual({});
});
it("ignores a reaction that does not relate to the sender's own membership", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$forged",
type: "m.reaction",
sender: "@mallory:example.org",
content: {
"m.relates_to": {
rel_type: "m.annotation",
event_id: "$alice-join",
key: "🖐️",
},
},
originServerTs: 1000,
});
expect(hands()).toEqual({});
});
it("picks up a hand raised before we looked, and drops it when the member leaves", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
// The reaction is already in the room when the roster arrives.
raisedHand(timeline);
expect(hands()).toEqual({});
rtcParticipationManager.setMemberships([alice]);
expect(Object.keys(hands())).toEqual([aliceId]);
rtcParticipationManager.setMemberships([]);
expect(hands()).toEqual({});
});
it("re-resolves a hand when the member re-sends their membership", () => {
const { rtcParticipationManager, timeline, hands } = setUp();
rtcParticipationManager.setMemberships([alice]);
raisedHand(timeline);
expect(Object.keys(hands())).toEqual([aliceId]);
// A new membership event without a hand on it: the hand goes.
const resent = fakeMembership({
member: { ...alice.member, eventId: "$alice-join-2" },
});
rtcParticipationManager.setMemberships([resent]);
expect(hands()).toEqual({});
// Raised again on the new event: back.
raisedHand(timeline, resent, "$hand-2");
expect(hands()[aliceId]?.membershipEventId).toBe("$alice-join-2");
});
it("shows a reaction keyed by the member's media id", () => {
const { rtcParticipationManager, timeline, reactions } = setUp();
rtcParticipationManager.setMemberships([alice]);
timeline.emitTimelineEvent({
eventId: "$reaction",
type: ElementCallReactionEventType,
sender: alice.member.userId,
content: {
"m.relates_to": { rel_type: "m.reference", event_id: "$alice-join" },
emoji: "🎉",
name: "party",
},
originServerTs: 1000,
});
expect(reactions()).toEqual(["🎉"]);
});
});
@@ -1,250 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { BehaviorSubject, delay } from "rxjs";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ElementCallReactionEventType,
GenericReaction,
type RaisedHandInfo,
type ReactionInfo,
ReactionSet,
} from ".";
import { REACTION_ACTIVE_TIME_MS } from "./ReactionsReader";
import { type Behavior } from "../state/Behavior";
import { type Epoch, type ObservableScope } from "../state/ObservableScope";
import { type FfiMembership } from "../matrix-rtc-sdk";
import {
type TimelineDriver,
type TimelineEvent,
} from "../driver/ElementCallMatrixClientDriver";
import { memberMediaId } from "../state/rtc/mediaId";
const RAISED_HAND_KEY = "🖐️";
const REACTION_EVENT_TYPE = "m.reaction";
const REDACTION_EVENT_TYPE = "m.room.redaction";
/** What the reader needs from a {@link RtcParticipationManager}. */
export interface ParticipationReactionsSource {
memberships$: Behavior<Epoch<FfiMembership[]>>;
}
interface Relation {
rel_type?: string;
event_id?: string;
key?: string;
}
function relationOf(content: Record<string, unknown>): Relation | undefined {
return content["m.relates_to"] as Relation | undefined;
}
/**
* Raised hands and reactions over a {@link RtcParticipationManager} and the client
* driver's timeline: the counterpart of {@link ReactionsReader}, which reads
* the same from a matrix-js-sdk session.
*
* Hands and reactions relate to the sender's current membership event, as
* they always have. Both outputs are keyed by the member's media id
* (`${userId}:${deviceId}`), which is what the tiles are keyed by.
*/
export class ParticipationReactionsReader {
private readonly raisedHandsSubject$ = new BehaviorSubject<
Record<string, RaisedHandInfo>
>({});
public readonly raisedHands$ = this.raisedHandsSubject$.asObservable();
private readonly reactionsSubject$ = new BehaviorSubject<
Record<string, ReactionInfo>
>({});
public readonly reactions$ = this.reactionsSubject$.asObservable();
private memberships: FfiMembership[] = [];
public constructor(
scope: ObservableScope,
rtcParticipationManager: ParticipationReactionsSource,
private readonly timeline: Pick<
TimelineDriver,
"subscribeTimeline" | "getRelatedEvents"
>,
) {
// Hide reactions after a given time.
this.reactionsSubject$
.pipe(delay(REACTION_ACTIVE_TIME_MS), scope.bind())
.subscribe((reactions) => {
const date = new Date();
const nextEntries = Object.fromEntries(
Object.entries(reactions).filter(([_, hr]) => hr.expireAfter > date),
);
if (Object.keys(reactions).length === Object.keys(nextEntries).length)
return;
this.reactionsSubject$.next(nextEntries);
});
scope.onEnd(timeline.subscribeTimeline(this.handleEvent));
rtcParticipationManager.memberships$
.pipe(scope.bind())
.subscribe((memberships) => this.onMembershipsChanged(memberships.value));
}
/** The member whose current membership event is `eventId`, sent by `sender`. */
private memberFor(
eventId: string | undefined,
sender: string,
): FfiMembership | undefined {
if (eventId === undefined) return undefined;
return this.memberships.find(
(m) => m.member.eventId === eventId && m.member.userId === sender,
);
}
/** The hand `member` has raised on their current membership event, if any. */
private findRaisedHand(member: FfiMembership): RaisedHandInfo | undefined {
const eventId = member.member.eventId;
if (eventId === undefined) return undefined;
const reaction = this.timeline
.getRelatedEvents(eventId, "m.annotation", REACTION_EVENT_TYPE)
.find(
(e) =>
e.sender === member.member.userId &&
relationOf(e.content)?.key === RAISED_HAND_KEY,
);
if (reaction === undefined) return undefined;
return {
membershipEventId: eventId,
reactionEventId: reaction.eventId,
time: new Date(reaction.originServerTs),
};
}
/**
* Drops the hands of members who left, keeps a hand across a re-sent
* membership when it was raised again on the new event, and picks up hands
* that were raised before we looked.
*/
private onMembershipsChanged(memberships: FfiMembership[]): void {
this.memberships = memberships;
const present = new Map(
memberships.map((m) => [memberMediaId(m.member), m]),
);
const hands = { ...this.raisedHandsSubject$.value };
let changed = false;
for (const id of Object.keys(hands)) {
const member = present.get(id);
if (member === undefined) {
delete hands[id];
changed = true;
} else if (hands[id].membershipEventId !== member.member.eventId) {
// The member re-sent their membership: the hand stands only if it
// was raised on the new event too.
const hand = this.findRaisedHand(member);
if (hand) hands[id] = hand;
else delete hands[id];
changed = true;
}
}
for (const [id, member] of present) {
if (id in hands) continue;
const hand = this.findRaisedHand(member);
if (hand) {
hands[id] = hand;
changed = true;
}
}
if (changed) this.raisedHandsSubject$.next(hands);
}
private addRaisedHand(identifier: string, info: RaisedHandInfo): void {
this.raisedHandsSubject$.next({
...this.raisedHandsSubject$.value,
[identifier]: info,
});
}
private removeRaisedHand(identifier: string): void {
this.raisedHandsSubject$.next(
Object.fromEntries(
Object.entries(this.raisedHandsSubject$.value).filter(
([id]) => id !== identifier,
),
),
);
}
private handleEvent = (event: TimelineEvent): void => {
if (event.type === ElementCallReactionEventType) {
const relation = relationOf(event.content);
const member = this.memberFor(relation?.event_id, event.sender);
if (member === undefined) {
logger.warn(
`Reaction target was not a membership event for ${event.sender}, ignoring`,
);
return;
}
const identifier = memberMediaId(member.member);
const rawEmoji = event.content.emoji;
if (typeof rawEmoji !== "string" || rawEmoji === "") {
logger.warn(`Reaction had no emoji from ${event.eventId}`);
return;
}
const emoji = new Intl.Segmenter(undefined, { granularity: "grapheme" })
.segment(rawEmoji)
[Symbol.iterator]()
.next().value?.segment;
if (!emoji?.trim()) {
logger.warn(
`Reaction had no emoji from ${event.eventId} after splitting`,
);
return;
}
const reaction = {
...GenericReaction,
emoji,
// If we don't find a reaction, we can fallback to the generic sound.
...ReactionSet.find((r) => r.name === event.content.name),
};
const current = this.reactionsSubject$.value;
if (current[identifier]) {
// We've still got a reaction from this user, ignore it to prevent spamming
logger.warn(`Got reaction from ${identifier} but one is still playing`);
return;
}
this.reactionsSubject$.next({
...current,
[identifier]: {
reactionOption: reaction,
expireAfter: new Date(Date.now() + REACTION_ACTIVE_TIME_MS),
},
});
} else if (event.type === REACTION_EVENT_TYPE) {
const relation = relationOf(event.content);
const member = this.memberFor(relation?.event_id, event.sender);
if (member === undefined) {
logger.warn(
`Reaction target was not a membership event for ${event.sender}, ignoring`,
);
return;
}
if (relation?.key === RAISED_HAND_KEY)
this.addRaisedHand(memberMediaId(member.member), {
reactionEventId: event.eventId,
membershipEventId: relation.event_id!,
time: new Date(event.originServerTs),
});
} else if (event.type === REDACTION_EVENT_TYPE) {
const redacts =
event.redacts ?? (event.content.redacts as string | undefined);
const target = Object.entries(this.raisedHandsSubject$.value).find(
([, hand]) => hand.reactionEventId === redacts,
)?.[0];
if (target !== undefined) this.removeRaisedHand(target);
}
};
}
+72 -62
View File
@@ -5,18 +5,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { EventType, type MatrixClient, RelationType } from "matrix-js-sdk";
import { EventType, RelationType } from "matrix-js-sdk";
import {
createContext,
use,
type ReactNode,
useCallback,
useMemo,
type JSX,
} from "react";
import { type MatrixRTCSession } from "matrix-js-sdk/lib/matrixrtc";
import { logger } from "matrix-js-sdk/lib/logger";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import { useClientState } from "../ClientContext";
import { type TimelineDriver } from "../driver/ElementCallMatrixClientDriver";
import { ElementCallReactionEventType, type ReactionOption } from ".";
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
import { useBehavior } from "../useBehavior";
@@ -42,108 +44,116 @@ export const useReactionsSender = (): ReactionsSenderContextType => {
/**
* Provider that handles sending a reaction or hand raised event to a call.
*/
/** How to send and take back a reaction: a room event, and a redaction. */
export type ReactionsTimeline = Pick<
TimelineDriver,
"sendRoomEvent" | "redactEvent"
>;
/** {@link ReactionsTimeline} over a matrix-js-sdk client. */
export function jsSdkReactionsTimeline(
client: Pick<MatrixClient, "sendEvent" | "redactEvent">,
roomId: string,
): ReactionsTimeline {
return {
sendRoomEvent: async (eventType, content) => {
const { event_id: eventId } = await client.sendEvent(
roomId,
eventType as never,
content as never,
);
return { eventId };
},
redactEvent: async (eventId) => {
await client.redactEvent(roomId, eventId);
},
};
}
export const ReactionsSenderProvider = ({
children,
rtcSession,
vm,
ownIdentifier,
ownMembershipEventId,
timeline,
}: {
children: ReactNode;
rtcSession: MatrixRTCSession;
vm: CallViewModel;
/** Our key in `vm.reactions$` / `vm.handsRaised$` (`${userId}:${deviceId}`). */
ownIdentifier: string;
/** The event id of our current membership, which reactions relate to. */
ownMembershipEventId: string | undefined;
timeline: ReactionsTimeline;
}): JSX.Element => {
// A widget host may forbid reactions; without a client state (the
// component, the crate path) there is nobody to forbid them.
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const clientState = useClientState();
const supportsReactions =
clientState === undefined ||
(clientState.state === "valid" && clientState.supportedFeatures.reactions);
clientState?.state === "valid" && clientState.supportedFeatures.reactions;
const room = rtcSession.room;
const myUserId = room.client.getUserId();
const myDeviceId = room.client.getDeviceId();
const myMembershipIdentifier = `${myUserId}:${myDeviceId}`;
const myMembershipEvent = useMemo(
() =>
memberships.find(
(m) => m.userId === myUserId && m.deviceId === myDeviceId,
)?.eventId,
[memberships, myUserId, myDeviceId],
);
const reactions = useBehavior(vm.reactions$);
const myReaction = reactions[ownIdentifier];
const myReaction = useMemo(
() =>
myMembershipIdentifier !== undefined
? reactions[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, reactions],
);
const handsRaised = useBehavior(vm.handsRaised$);
const myRaisedHand = handsRaised[ownIdentifier];
const myRaisedHand = useMemo(
() =>
myMembershipIdentifier !== undefined
? handsRaised[myMembershipIdentifier]
: undefined,
[myMembershipIdentifier, handsRaised],
);
const toggleRaisedHand = useCallback(async () => {
if (!myMembershipIdentifier) {
return;
}
const myReactionId = myRaisedHand?.reactionEventId;
if (!myReactionId) {
try {
if (!ownMembershipEventId) {
if (!myMembershipEvent) {
throw new Error("Cannot find own membership event");
}
const { eventId } = await timeline.sendRoomEvent(EventType.Reaction, {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: ownMembershipEventId,
key: "🖐️",
const reaction = await room.client.sendEvent(
rtcSession.room.roomId,
EventType.Reaction,
{
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: myMembershipEvent,
key: "🖐️",
},
},
});
logger.debug("Sent raise hand event", eventId);
);
logger.debug("Sent raise hand event", reaction.event_id);
} catch (ex) {
logger.error("Failed to send raised hand", ex);
}
} else {
try {
await timeline.redactEvent(myReactionId);
await room.client.redactEvent(rtcSession.room.roomId, myReactionId);
logger.debug("Redacted raise hand event");
} catch (ex) {
logger.error("Failed to redact reaction event", myReactionId, ex);
throw ex;
}
}
}, [ownMembershipEventId, myRaisedHand, timeline]);
}, [
myMembershipEvent,
myMembershipIdentifier,
myRaisedHand,
rtcSession,
room,
]);
const sendReaction = useCallback(
async (reaction: ReactionOption) => {
if (myReaction) {
if (!myMembershipIdentifier || myReaction) {
// We're still reacting
return;
}
if (!ownMembershipEventId) {
if (!myMembershipEvent) {
throw new Error("Cannot find own membership event");
}
await timeline.sendRoomEvent(ElementCallReactionEventType, {
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: ownMembershipEventId,
await room.client.sendEvent(
rtcSession.room.roomId,
ElementCallReactionEventType,
{
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: myMembershipEvent,
},
emoji: reaction.emoji,
name: reaction.name,
},
emoji: reaction.emoji,
name: reaction.name,
});
);
},
[ownMembershipEventId, myReaction, timeline],
[myMembershipEvent, myReaction, room, myMembershipIdentifier, rtcSession],
);
return (
+5 -2
View File
@@ -6,12 +6,13 @@ Please see LICENSE in the repository root for full details.
*/
import { type FC, type FormEventHandler, useCallback, useState } from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { Trans, useTranslation } from "react-i18next";
import { Button, Heading, Text } from "@vector-im/compound-web";
import styles from "./CallEndedView.module.css";
import feedbackStyle from "../input/FeedbackInput.module.css";
import { useOwnProfile } from "../profile/useOwnProfile";
import { useProfile } from "../profile/useProfile";
import { Header, HeaderLogo, LeftNav, RightNav } from "../Header";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import { FieldRow, InputField } from "../input/Input";
@@ -21,6 +22,7 @@ import { LeaveToHomeLink } from "../button/LeaveToHomeLink";
import { useLeaveToHome } from "../LeaveToHomeContext";
interface Props {
client: MatrixClient;
isPasswordlessUser: boolean;
hideHeader: boolean;
confineToRoom: boolean;
@@ -28,6 +30,7 @@ interface Props {
}
export const CallEndedView: FC<Props> = ({
client,
isPasswordlessUser,
hideHeader,
confineToRoom,
@@ -36,7 +39,7 @@ export const CallEndedView: FC<Props> = ({
const { t } = useTranslation();
const leaveToHome = useLeaveToHome();
const { displayName } = useOwnProfile();
const { displayName } = useProfile(client);
const [surveySubmitted, setSurveySubmitted] = useState(false);
const [starRating, setStarRating] = useState(0);
const [submitting, setSubmitting] = useState(false);
-225
View File
@@ -1,225 +0,0 @@
/*
Copyright 2026 Element Creations Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
/**
* `CallView` over the mock drivers: no matrix-js-sdk client, the Rust crate
* carrying the call. The lobby and the ended screen render as they would for
* a host; the in-call screen needs a LiveKit SFU, which no mock provides yet,
* so its story shows the path a call takes when the homeserver advertises no
* transport instead.
*/
import type { Meta, StoryObj } from "@storybook/react-vite";
import { type FC, type ReactNode, useEffect, useMemo, useState } from "react";
import { BrowserRouter } from "react-router-dom";
import { CallView } from "./CallView";
import { CallEndedView } from "./CallEndedView";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../driver/MatrixDriverContext";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import {
MockRtcMatrixDriver,
type MockRtcMatrixDriverOptions,
slotEvent,
} from "../driver/MockRtcMatrixDriver";
import { HostBridgeProvider, nullHostBridge } from "../HostBridge";
import { RootElementProvider } from "../RootElementContext";
import {
componentProperties,
configurationForIntent,
type UrlParams,
UrlParamsProvider,
UserIntent,
} from "../UrlParams";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { MediaDevices } from "../state/MediaDevices";
import { ObservableScope } from "../state/ObservableScope";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
const ROOM_ID = "!story:example.org";
const USER_ID = "@me:example.org";
const DEVICE_ID = "STORYDEV";
const alice = {
userId: "@alice:example.org",
deviceId: "ALICEDEV",
memberId: "m-alice",
};
interface HostProps {
intent: UserIntent;
/** Members already in the call when the view mounts. */
peers?: (typeof alice)[];
/** The RTC driver's options, e.g. no transports for the error path. */
rtc?: MockRtcMatrixDriverOptions;
children: ReactNode;
}
/**
* What a host provides around a call: the drivers, the host bridge, the
* call's parameters, the media devices and the root element.
*/
const Host: FC<HostProps> = ({ intent, peers = [], rtc, children }) => {
const drivers = useMemo((): MatrixDrivers => {
const rtcDriver = new MockRtcMatrixDriver({
userId: USER_ID,
deviceId: DEVICE_ID,
roomId: ROOM_ID,
roomState: [slotEvent({ roomId: ROOM_ID, status: "open" })],
...rtc,
});
for (const peer of peers) rtcDriver.addPeer(peer);
return {
rtcDriver,
clientDriver: new MockElementCallMatrixClientDriver({
userId: USER_ID,
deviceId: DEVICE_ID,
roomId: ROOM_ID,
roomInfo: { name: "Weekly sync", joinRule: "public" },
ownProfile: { displayName: "Me", avatarUrl: null },
members: [
{
userId: USER_ID,
displayName: "Me",
avatarUrl: null,
membership: "join",
},
...peers.map((p) => ({
userId: p.userId,
displayName: p.userId.slice(1).split(":")[0],
avatarUrl: null,
membership: "join" as const,
})),
],
}),
};
}, [peers, rtc]);
const params = useMemo(
(): UrlParams => ({
...componentProperties,
roomId: ROOM_ID,
...configurationForIntent(intent),
}),
[intent],
);
// The peers join once the crate listens to the room: the participation is
// created after the wasm has loaded, so poll for its sink.
useEffect(() => {
if (peers.length === 0) return;
const rtcDriver = drivers.rtcDriver as MockRtcMatrixDriver;
let tries = 0;
const timer = setInterval(() => {
tries++;
try {
for (const peer of peers) rtcDriver.peerJoins(peer);
clearInterval(timer);
} catch {
if (tries > 200) clearInterval(timer);
}
}, 50);
return (): void => clearInterval(timer);
}, [drivers, peers]);
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMediaDevices(new MediaDevices(scope, { controlledAudioDevices: false }));
return (): void => {
setMediaDevices(null);
scope.end();
};
}, []);
const [root, setRoot] = useState<HTMLDivElement | null>(null);
return (
<BrowserRouter>
<HostBridgeProvider value={nullHostBridge}>
<UrlParamsProvider value={params}>
<MatrixDriverProvider value={drivers}>
<div
ref={setRoot}
style={{ width: "80vw", height: "80vh", position: "relative" }}
>
{root !== null && mediaDevices !== null && (
<RootElementProvider value={root}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<>{children}</>
</ProcessorProvider>
</MediaDevicesContext>
</RootElementProvider>
)}
</div>
</MatrixDriverProvider>
</UrlParamsProvider>
</HostBridgeProvider>
</BrowserRouter>
);
};
const meta: Meta<typeof CallView> = {
title: "Room/CallView",
component: CallView,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof CallView>;
/** The lobby of a call that Alice is already in. */
export const Lobby: Story = {
args: {
isPasswordlessUser: false,
confineToRoom: true,
preload: false,
skipLobby: false,
},
render: (args) => (
<Host intent={UserIntent.JoinExistingCall} peers={[alice]}>
<CallView {...args} />
</Host>
),
};
/**
* Straight into the call, with a homeserver that advertises no transport:
* the crate fails the join and the view shows the error it turns into.
*/
export const NoTransport: Story = {
args: {
isPasswordlessUser: false,
confineToRoom: true,
preload: false,
skipLobby: true,
},
render: (args) => (
<Host intent={UserIntent.StartNewCall} rtc={{ transports: [] }}>
<CallView {...args} />
</Host>
),
};
/** The screen after leaving, as a passwordless user sees it. */
export const Ended: Story = {
args: {
isPasswordlessUser: true,
confineToRoom: false,
preload: false,
skipLobby: false,
},
render: (args) => (
<Host intent={UserIntent.JoinExistingCall}>
<CallEndedView
endedCallId={ROOM_ID}
isPasswordlessUser={args.isPasswordlessUser}
hideHeader={false}
confineToRoom={args.confineToRoom}
/>
</Host>
),
};
+7 -85
View File
@@ -11,7 +11,6 @@ Please see LICENSE in the repository root for full details.
import {
beforeEach,
describe,
expect,
type MockedFunction,
onTestFinished,
@@ -65,15 +64,6 @@ import { MatrixRTCTransportMissingError } from "../utils/errors";
import { ProcessorProvider } from "../livekit/TrackProcessorContext";
import { MediaDevicesContext } from "../MediaDevicesContext";
import { constant } from "../state/Behavior";
import {
type MatrixDrivers,
MatrixDriverProvider,
} from "../driver/MatrixDriverContext";
import { MockRtcMatrixDriver } from "../driver/MockRtcMatrixDriver";
import { MockElementCallMatrixClientDriver } from "../driver/MockElementCallMatrixClientDriver";
import { initMatrixRtcSdkForTests } from "../utils/test-matrix-rtc";
import { callViewModelImplementation } from "../settings/settings";
import { CallViewModelImplementation } from "../config/ConfigOptions";
vi.mock("../soundUtils");
vi.mock("../useAudioContext");
@@ -148,23 +138,6 @@ beforeEach(() => {
);
});
/** Mock drivers over the same room and identity as the mocked session. */
function defaultDrivers(): MatrixDrivers {
return {
rtcDriver: new MockRtcMatrixDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
clientDriver: new MockElementCallMatrixClientDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
roomInfo: { joinRule: "invite" },
}),
};
}
function createCallView(
hostBridge: HostBridge,
joined = true,
@@ -172,8 +145,6 @@ function createCallView(
withErrorBoundary?: boolean;
/** Wait for the host to say when to join, rather than joining at once. */
preload?: boolean;
/** The host's drivers, for the matrix-rtc implementation. */
drivers?: MatrixDrivers;
} = {},
): {
rtcSession: MatrixRTCSession;
@@ -223,15 +194,13 @@ function createCallView(
<TooltipProvider>
<MediaDevicesContext value={mockMediaDevices({})}>
<ProcessorProvider>
<MatrixDriverProvider value={options.drivers ?? defaultDrivers()}>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView}
</GroupCallErrorBoundary>
) : (
callView
)}
</MatrixDriverProvider>
{options.withErrorBoundary ? (
<GroupCallErrorBoundary recoveryActionHandler={vi.fn()}>
{callView}
</GroupCallErrorBoundary>
) : (
callView
)}
</ProcessorProvider>
</MediaDevicesContext>
</TooltipProvider>
@@ -455,50 +424,3 @@ test("user can reconnect after a membership manager error", async () => {
// In-call controls should be visible again
await waitFor(() => screen.getByRole("button", { name: "Leave" }));
});
describe("the call implementation switch", () => {
test("matrix-js-sdk carries the call by default: no participation", async () => {
createCallView(nullHostBridge);
await waitFor(() => expect(ActiveCall).toHaveBeenCalled());
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).toBeNull();
expect(window.matrixRtc).toBeUndefined();
});
test("with matrix-rtc selected and drivers provided, the crate carries the call", async () => {
await initMatrixRtcSdkForTests();
callViewModelImplementation.setValue(CallViewModelImplementation.MatrixRtc);
onTestFinished(() =>
callViewModelImplementation.setValue(
CallViewModelImplementation.MatrixJsSdk,
),
);
const drivers: MatrixDrivers = {
rtcDriver: new MockRtcMatrixDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
clientDriver: new MockElementCallMatrixClientDriver({
userId: localRtcMember.userId,
deviceId: localRtcMember.deviceId,
roomId,
}),
};
createCallView(nullHostBridge, true, { drivers });
await waitFor(() =>
expect(
vi.mocked(ActiveCall).mock.calls.at(-1)?.[0].rtcParticipationManager,
).not.toBeNull(),
);
const { rtcParticipationManager } = vi
.mocked(ActiveCall)
.mock.calls.at(-1)![0];
expect(window.matrixRtc?.rtcParticipationManager).toBe(
rtcParticipationManager,
);
// The participation is bound to the drivers' room and identity.
expect(rtcParticipationManager?.session$.value.roomId).toBe(roomId);
});
});
+72 -189
View File
@@ -12,11 +12,11 @@ import {
useEffect,
useMemo,
useState,
useRef,
} from "react";
import {
type MatrixClient,
JoinRule,
type Room,
UnsupportedStickyEventsEndpointError,
} from "matrix-js-sdk";
import {
@@ -34,11 +34,7 @@ import { LobbyView } from "./LobbyView";
import { type MatrixInfo } from "./VideoPreview";
import { CallEndedView } from "./CallEndedView";
import { PosthogAnalytics } from "../analytics/PosthogAnalytics";
import {
mediaKeyStatisticsOf,
NO_MEDIA_KEY_STATISTICS,
} from "../analytics/PosthogEvents";
import { useOwnProfile } from "../profile/useOwnProfile";
import { useProfile } from "../profile/useProfile";
import { findDeviceByName } from "../utils/media";
import { ActiveCall } from "./InCallView";
import { type MuteStates } from "../state/MuteStates";
@@ -46,9 +42,11 @@ import { useMediaDevices } from "../MediaDevicesContext";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships";
import {
saveKeyForRoom,
useEncryptionSystemFor,
useRoomEncryptionSystem,
} from "../e2ee/sharedKeyManagement";
import { useRoomInfo } from "./useRoomInfo";
import { useRoomAvatar } from "./useRoomAvatar";
import { useRoomName } from "./useRoomName";
import { useJoinRule } from "./useJoinRule";
import { InviteModal } from "./InviteModal";
import { HeaderStyle, type UrlParams, useUrlParams } from "../UrlParams";
import { E2eeType } from "../e2ee/e2eeType";
@@ -66,6 +64,7 @@ import {
UnknownCallError,
} from "../utils/errors.ts";
import { GroupCallErrorBoundary } from "./GroupCallErrorBoundary.tsx";
import { useTypedEventEmitter } from "../useEvents";
import { muteAllAudio$ } from "../state/MuteAllAudioModel.ts";
import { useAppBarTitle } from "../AppBar.tsx";
import { useBehavior } from "../useBehavior.ts";
@@ -73,20 +72,6 @@ import { useRootElement } from "../RootElementContext.ts";
import { useHostBridge } from "../HostBridge.ts";
import { useMuteStates } from "../state/useMuteStates.ts";
import { useLeaveToHome } from "../LeaveToHomeContext.ts";
import { useMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { useRtcParticipationManager } from "../state/rtc/useRtcParticipationManager.ts";
import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
import { participationConfig } from "../state/rtc/joinParams.ts";
import { effectiveCallViewModelImplementation } from "../state/rtc/implementation.ts";
import {
CallViewModelImplementation,
type MatrixRTCMode,
} from "../config/ConfigOptions.ts";
import { Config } from "../config/Config.ts";
import { matrixRTCMode as matrixRTCModeSetting } from "../settings/settings.ts";
import { constant } from "../state/Behavior.ts";
import { Epoch } from "../state/ObservableScope.ts";
import { FfiStatus, type FfiMembership } from "../matrix-rtc-sdk";
/**
* If there already are this many participants in the call, we automatically mute
@@ -97,26 +82,14 @@ export const MUTE_PARTICIPANT_COUNT = 8;
declare global {
interface Window {
rtcSession?: MatrixRTCSession;
/** The crate's participation manager, when the Rust implementation carries the call. */
matrixRtc?: { rtcParticipationManager: RtcParticipationManager };
}
}
/** What the crate's roster reads as while matrix-js-sdk carries the call. */
const NO_PARTICIPATION_MEMBERSHIPS = constant(
new Epoch<FfiMembership[]>([], 0),
);
interface Props {
/**
* The matrix-js-sdk client and session, for the matrix-js-sdk
* implementation of the call. Without them the Rust crate carries the call
* through the drivers whatever the developer setting says (the component
* has no client to offer).
*/
client?: MatrixClient;
/** The client to place the call with. */
client: MatrixClient;
/** The call to join. */
rtcSession?: MatrixRTCSession;
rtcSession: MatrixRTCSession;
/**
* Whether the user is signed in as a guest, and so should be offered the
* chance to create an account when the call ends.
@@ -188,60 +161,7 @@ const LoadedCallView: FC<LoadedProps> = ({
const [externalError, setExternalError] = useState<ElementCallError | null>(
null,
);
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
// The host's drivers: what the room is called and looks like, who we are,
// and (on the crate path) the session itself come from them.
const drivers = useMatrixDrivers();
const { roomId } = drivers.clientDriver;
const roomInfo = useRoomInfo();
const e2eeSystem = useEncryptionSystemFor(roomId, roomInfo.encrypted);
// Which implementation carries this call, sampled once for the view's
// lifetime (§5.15 of the oxidation plan): the Rust crate through the
// host's drivers, or matrix-js-sdk's session — which needs a session.
const [implementation] = useState(() =>
effectiveCallViewModelImplementation(),
);
// this is the rustsdkmatrix rtc. So we should call it useRustRtcSdk
const useRustRtcSdk =
implementation === CallViewModelImplementation.MatrixRtc ||
rtcSession === undefined;
// Sampled once, like the implementation: the participation is the call,
// and rebuilding it for a later change of these would leave and rejoin.
const [participationConfigValue] = useState(() =>
useRustRtcSdk
? participationConfig({
// matrix_rtc_mode in config.json overrides the user's choice.
mode:
(Config.get().matrix_rtc_mode as MatrixRTCMode | undefined) ??
matrixRTCModeSetting.value$.value,
manageMediaKeys: e2eeSystem.kind === E2eeType.PER_PARTICIPANT,
session: Config.get().matrix_rtc_session,
})
: null,
);
const rtcParticipationManager = useRtcParticipationManager(
useRustRtcSdk ? drivers : null,
participationConfigValue,
{
// Element Call's own `config.json` LiveKit service, for a homeserver
// that advertises no transport: the precedence Element Call has always
// had, and the route by which a host (Element Web) hands over what it
// found in `.well-known`.
transportFallbackUrl: Config.get().livekit?.livekit_service_url,
},
);
const participationMemberships = useBehavior(
rtcParticipationManager?.memberships$ ?? NO_PARTICIPATION_MEMBERSHIPS,
);
// The call's members, whichever side lists them; only who they are matters here.
const memberUserIds = useMemo(
() =>
useRustRtcSdk
? participationMemberships.value.map((m) => m.member.userId)
: jsSdkMemberships.map((m) => m.userId!),
[useRustRtcSdk, participationMemberships, jsSdkMemberships],
);
const memberships = useMatrixRTCSessionMemberships(rtcSession);
const rootElement = useRootElement();
const hostBridge = useHostBridge();
// A host that can close us is a host that decides when we stop existing, so
@@ -258,16 +178,12 @@ const LoadedCallView: FC<LoadedProps> = ({
muted: muteAllAudio,
}),
);
// Joining a big call starts muted. Decided once, the first time the roster
// is known: at mount for matrix-js-sdk, after the seed for the crate.
const mutedForCallSize = useRef(false);
// This should use `useEffectEvent` (only available in experimental versions)
useEffect(() => {
if (mutedForCallSize.current) return;
if (useRustRtcSdk && !participationMemberships.value.length) return;
mutedForCallSize.current = true;
if (memberUserIds.length >= MUTE_PARTICIPANT_COUNT)
if (memberships.length >= MUTE_PARTICIPANT_COUNT)
muteStates.audio.setEnabled$.value?.(false);
}, [useRustRtcSdk, participationMemberships, memberUserIds, muteStates]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
logger.info("[Lifecycle] CallView Component mounted");
@@ -287,22 +203,18 @@ const LoadedCallView: FC<LoadedProps> = ({
}, [rootElement]);
useEffect(() => {
// Storing in the window to access it for the rageshake summary.
if (rtcSession !== undefined) window.rtcSession = rtcSession;
if (rtcParticipationManager !== null)
window.matrixRtc = { rtcParticipationManager };
window.rtcSession = rtcSession;
return (): void => {
delete window.rtcSession;
delete window.matrixRtc;
};
}, [rtcSession, rtcParticipationManager]);
}, [rtcSession]);
// TODO move this into the callViewModel LocalMembership.ts
// We might actually not need this at all. Since we get into fatalError on those errors already?
// matrix-js-sdk only; the crate reports these through `fatalError$`.
useEffect(() => {
if (rtcSession === undefined) return;
const onError = (error: unknown): void => {
useTypedEventEmitter(
rtcSession,
MatrixRTCSessionEvent.MembershipManagerError,
(error) => {
// When matrix_rtc_mode=matrix_2_0 is in effect but the homeserver does
// not advertise MSC4354 (sticky events), the SDK throws an
// `UnsupportedStickyEventsEndpointError`. The MembershipManager
@@ -315,93 +227,79 @@ const LoadedCallView: FC<LoadedProps> = ({
} else {
setExternalError(new ConnectionLostError());
}
};
rtcSession.on(MatrixRTCSessionEvent.MembershipManagerError, onError);
return (): void => {
rtcSession.off(MatrixRTCSessionEvent.MembershipManagerError, onError);
};
}, [rtcSession]);
},
);
useEffect(() => {
if (client === undefined || rtcSession === undefined) return;
// Sanity check the room object
if (client.getRoom(rtcSession.room.roomId) !== rtcSession.room)
logger.warn(
`We've ended up with multiple rooms for the same ID (${rtcSession.room.roomId}). This indicates a bug in the group call loading code, and may lead to incomplete room state.`,
);
}, [client, rtcSession]);
}, [client, rtcSession.room]);
const { displayName, avatarUrl } = useOwnProfile();
const roomName = roomInfo.name;
const roomAvatar = roomInfo.avatarUrl;
const room = rtcSession.room as Room;
const { displayName, avatarUrl } = useProfile(client);
const roomName = useRoomName(room);
const roomAvatar = useRoomAvatar(room);
const {
perParticipantE2EE,
returnToLobby,
password: passwordFromUrl,
header,
} = useUrlParams();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
// Save the password once we start the groupCallView
useEffect(() => {
if (passwordFromUrl) saveKeyForRoom(roomId, passwordFromUrl);
}, [passwordFromUrl, roomId]);
if (passwordFromUrl) saveKeyForRoom(room.roomId, passwordFromUrl);
}, [passwordFromUrl, room.roomId]);
useAppBarTitle(roomName);
const { userId: ownUserId } = drivers.clientDriver;
const roomAlias = roomInfo.canonicalAlias;
const matrixInfo = useMemo((): MatrixInfo => {
return {
userId: ownUserId,
displayName: displayName ?? ownUserId,
avatarUrl: avatarUrl ?? "",
roomId,
userId: client.getUserId()!,
displayName: displayName!,
avatarUrl: avatarUrl!,
roomId: room.roomId,
roomName,
roomAlias,
roomAlias: room.getCanonicalAlias(),
roomAvatar,
e2eeSystem,
};
}, [
ownUserId,
displayName,
avatarUrl,
roomName,
roomId,
roomAlias,
roomAvatar,
e2eeSystem,
]);
}, [client, displayName, avatarUrl, roomName, room, roomAvatar, e2eeSystem]);
// Count each member only once, regardless of how many devices they use
const participantCount = useMemo(
() => new Set<string>(memberUserIds).size,
[memberUserIds],
() => new Set<string>(memberships.map((m) => m.userId!)).size,
[memberships],
);
const mediaDevices = useMediaDevices();
const latestMuteStates = useLatest(muteStates);
// Read at leave time, not a dependency: `onLeft` feeds the in-call view's
// effect, and a roster change must not rebuild the call.
const latestMemberUserIds = useLatest(memberUserIds);
const enterRTCSessionOrError = useCallback(async (): Promise<void> => {
try {
setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
// @BillCarsonFr
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
const enterRTCSessionOrError = useCallback(
async (rtcSession: MatrixRTCSession): Promise<void> => {
try {
setJoined(true);
// TODO-MULTI-SFU what to do with error handling now that we don't use this function?
// @BillCarsonFr
} catch (e) {
if (e instanceof ElementCallError) {
setExternalError(e);
} else {
logger.error(`Unknown Error while entering RTC session`, e);
const error = new UnknownCallError(
e instanceof Error ? e : new Error("Unknown error", { cause: e }),
);
setExternalError(error);
}
}
}
return Promise.resolve();
}, [setJoined]);
return Promise.resolve();
},
[setJoined],
);
useEffect(() => {
const defaultDeviceSetup = async ({
@@ -511,14 +409,10 @@ const LoadedCallView: FC<LoadedProps> = ({
// queuing/batching of requests.
const sendInstantly = hostControlsLifetime;
PosthogAnalytics.instance.eventCallEnded.track(
roomId,
latestMemberUserIds.current.length,
room.roomId,
rtcSession.memberships.length,
sendInstantly,
rtcParticipationManager !== null
? rtcParticipationManager.mediaKeyStatistics()
: rtcSession === undefined
? NO_MEDIA_KEY_STATISTICS
: mediaKeyStatisticsOf(rtcSession),
rtcSession,
);
// Unfortunately the PostHog library provides no way to await the
// tracking of an event, but we don't really want it to hold up our
@@ -563,10 +457,8 @@ const LoadedCallView: FC<LoadedProps> = ({
leaveSoundContext,
hostBridge,
hostControlsLifetime,
roomId,
latestMemberUserIds,
room.roomId,
rtcSession,
rtcParticipationManager,
isPasswordlessUser,
confineToRoom,
returnToLobby,
@@ -582,7 +474,7 @@ const LoadedCallView: FC<LoadedProps> = ({
});
}, [hostBridge, joined, rtcSession]);
const joinRule = roomInfo.joinRule;
const joinRule = useJoinRule(room);
const [shareModalOpen, setInviteModalOpen] = useState(false);
const onDismissInviteModal = useCallback(
@@ -603,9 +495,7 @@ const LoadedCallView: FC<LoadedProps> = ({
const shareModal = (
<InviteModal
roomId={roomId}
roomName={roomName}
e2eeSystem={e2eeSystem}
room={room}
open={shareModalOpen}
onDismiss={onDismissInviteModal}
/>
@@ -614,7 +504,7 @@ const LoadedCallView: FC<LoadedProps> = ({
<>
{shareModal}
<LobbyView
developerSettingsClient={client}
client={client}
matrixInfo={matrixInfo}
muteStates={muteStates}
onEnter={() => setJoined(true)}
@@ -635,10 +525,6 @@ const LoadedCallView: FC<LoadedProps> = ({
throw externalError;
};
body = <ErrorComponent />;
} else if (joined && useRustRtcSdk && rtcParticipationManager === null) {
// Joined before the crate is ready (its wasm loads on first use): the
// call appears with the participation manager, a render later.
body = null;
} else if (joined) {
body = (
<>
@@ -646,9 +532,8 @@ const LoadedCallView: FC<LoadedProps> = ({
<ActiveCall
client={client}
matrixInfo={matrixInfo}
rtcSession={rtcSession}
rtcParticipationManager={rtcParticipationManager}
roomId={roomId}
rtcSession={rtcSession as MatrixRTCSession}
matrixRoom={room}
onLeft={onLeft}
muteStates={muteStates}
e2eeSystem={e2eeSystem}
@@ -669,7 +554,8 @@ const LoadedCallView: FC<LoadedProps> = ({
if (isPasswordlessUser || PosthogAnalytics.instance.isEnabled()) {
body = (
<CallEndedView
endedCallId={roomId}
endedCallId={rtcSession.room.roomId}
client={client}
isPasswordlessUser={isPasswordlessUser}
hideHeader={header === HeaderStyle.None}
confineToRoom={confineToRoom}
@@ -697,16 +583,13 @@ const LoadedCallView: FC<LoadedProps> = ({
setExternalError(null);
if (action == "reconnect") {
setLeft(false);
await enterRTCSessionOrError().catch((e) => {
await enterRTCSessionOrError(rtcSession).catch((e) => {
logger.error("Error re-entering RTC session", e);
});
}
}}
onError={(_error) => {
const joinedViaCrate =
rtcParticipationManager !== null &&
FfiStatus.Connected.instanceOf(rtcParticipationManager.status$.value);
if (rtcSession?.isJoined() === true || joinedViaCrate) onLeft("error");
if (rtcSession.isJoined()) onLeft("error");
// If there is an error we need to be dismissible again. This is done in
// `onLeft` as well; we need it here explicitly in case
// rtcSession.isJoined is false.
+14 -19
View File
@@ -42,10 +42,7 @@ import {
type CallViewModelOptions,
} from "../state/CallViewModel/CallViewModel";
import { alice, local } from "../utils/test-fixtures";
import {
jsSdkReactionsTimeline,
ReactionsSenderProvider,
} from "../reactions/useReactionsSender";
import { ReactionsSenderProvider } from "../reactions/useReactionsSender";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { LivekitRoomAudioRenderer } from "../livekit/MatrixAudioRenderer";
import { MediaDevicesContext } from "../MediaDevicesContext";
@@ -163,7 +160,7 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
footerVm={footerVm}
developerSettingsVm={developerSettingsVm}
matrixInfo={matrixInfo}
roomId={room.roomId}
matrixRoom={room}
onShareClick={null}
/>
);
@@ -173,16 +170,16 @@ function createInCallView(args: CreateInCallViewArgs = {}): RenderResult & {
const renderResult = render(
<BrowserRouter>
<MediaDevicesContext value={mediaDevices}>
<ReactionsSenderProvider
vm={vm}
ownIdentifier={`${client.getUserId()}:${client.getDeviceId()}`}
ownMembershipEventId={undefined}
timeline={jsSdkReactionsTimeline(client, room.roomId)}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>{content}</RoomContext>
</TooltipProvider>
</ReactionsSenderProvider>
<ProcessorProvider>
<ReactionsSenderProvider
vm={vm}
rtcSession={rtcSession.asMockedSession()}
>
<TooltipProvider>
<RoomContext value={livekitRoom}>{content}</RoomContext>
</TooltipProvider>
</ReactionsSenderProvider>
</ProcessorProvider>
</MediaDevicesContext>
</BrowserRouter>,
);
@@ -253,8 +250,7 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
rtcParticipationManager={null}
roomId={matrixRoom.roomId}
matrixRoom={matrixRoom}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
@@ -304,8 +300,7 @@ describe("ActiveCall", () => {
<ActiveCall
client={matrixRoom.client}
rtcSession={rtcSession.asMockedSession()}
rtcParticipationManager={null}
roomId={matrixRoom.roomId}
matrixRoom={matrixRoom}
muteStates={mockMuteStates()}
matrixInfo={matrixInfo}
onShareClick={null}
+40 -132
View File
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
import { type MatrixClient } from "matrix-js-sdk";
import { type MatrixClient, type Room as MatrixRoom } from "matrix-js-sdk";
import {
type FC,
type PointerEvent as ReactPointerEvent,
@@ -45,7 +45,6 @@ import {
type CallViewModel,
callViewModelOptionsFromParams,
createCallViewModel$,
createJsClientCallViewModel$,
} from "../state/CallViewModel/CallViewModel.ts";
import { Grid, type TileProps } from "../grid/Grid";
import { SpotlightTile } from "../tile/SpotlightTile";
@@ -87,21 +86,12 @@ import { ObservableScope } from "../state/ObservableScope.ts";
import { CallFooter, type FooterSnapshot } from "../components/CallFooter.tsx";
import { SettingsIconButton } from "../button/Button.tsx";
import { createCallFooterViewModel } from "../components/CallFooterViewModel.tsx";
import { type RtcParticipationManager } from "../state/rtc/RtcParticipationManager.ts";
import { useOptionalMatrixDrivers } from "../driver/MatrixDriverContext.tsx";
import { ParticipationReactionsReader } from "../reactions/ParticipationReactionsReader.ts";
import { useMatrixRTCSessionMemberships } from "../useMatrixRTCSessionMemberships.ts";
import { jsSdkReactionsTimeline } from "../reactions/useReactionsSender.tsx";
import { CallViewModelImplementation } from "../config/ConfigOptions.ts";
import { createDeveloperSettingsTabViewModel } from "../settings/DeveloperSettingsTabViewModel.ts";
import { type DeveloperSettingsSnapshot } from "../settings/DeveloperSettingsTab.tsx";
import { type ViewModel } from "../state/ViewModel.ts";
import { RingingStatus } from "../tile/RingingStatus.tsx";
import { RingingAudioRenderer } from "./RingingAudioRenderer.tsx";
/** What `ownMembership$` reads as when matrix-js-sdk carries the call. */
const NO_OWN_MEMBERSHIP = constant(null);
declare module "react" {
interface CSSProperties {
"--call-view-safe-area-inset-top"?: string;
@@ -114,12 +104,6 @@ export interface ActiveCallProps extends Omit<
"vm" | "livekitRoom" | "connState" | "footerVm" | "developerSettingsVm"
> {
e2eeSystem: EncryptionSystem;
/**
* The crate's participation manager for the session when the Rust
* implementation carries this call (see `CallViewModelImplementation`); null when
* matrix-js-sdk's `rtcSession` does.
*/
rtcParticipationManager: RtcParticipationManager | null;
// TODO refactor those reasons into an enum
onLeft: (
reason: "user" | "timeout" | "decline" | "allOthersLeft" | "error",
@@ -141,77 +125,34 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
// The element we have to draw the call in: the page, or the container a host
// gave us. Its size, not the window's, decides how the call is laid out.
const rootElement = useRootElement();
// The drivers, where a host provided them; required with a participation manager.
const drivers = useOptionalMatrixDrivers();
const { rtcParticipationManager, rtcSession, client, roomId } = props;
if (rtcParticipationManager !== null && drivers === null)
throw new Error(
"A call over the matrix-rtc crate needs the Matrix drivers to be provided",
);
if (
rtcParticipationManager === null &&
(rtcSession === undefined || client === undefined)
)
throw new Error(
"A call needs either a participation (the crate) or a matrix-js-sdk client and session",
);
useEffect(() => {
rootLogger.info("START CALL VIEW SCOPE");
const scope = new ObservableScope();
const reactionsReader = new ReactionsReader(scope, props.rtcSession);
const { autoLeaveWhenOthersLeft, waitForCallPickup, sendNotificationType } =
urlParams;
const options = {
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
// We merely sample the current mode here, so the user would need to
// manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCModeSetting.value$.value,
windowSize$: scope.behavior(observeElementSize$(rootElement)),
};
let vm: CallViewModel;
if (rtcParticipationManager !== null && drivers !== null) {
rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixRtc}`,
);
const reactionsReader = new ParticipationReactionsReader(
scope,
rtcParticipationManager,
drivers.clientDriver,
);
vm = createCallViewModel$(
scope,
rtcParticipationManager,
drivers.clientDriver,
mediaDevices,
props.muteStates,
options,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
} else if (rtcSession !== undefined) {
rootLogger.info(
`Call view model implementation: ${CallViewModelImplementation.MatrixJsSdk}`,
);
const reactionsReader = new ReactionsReader(scope, rtcSession);
vm = createJsClientCallViewModel$(
scope,
rtcSession,
rtcSession.room,
mediaDevices,
props.muteStates,
options,
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
} else {
return; // unreachable: checked above
}
const vm = createCallViewModel$(
scope,
props.rtcSession,
props.matrixRoom,
mediaDevices,
props.muteStates,
{
...callViewModelOptionsFromParams(urlParams),
encryptionSystem: props.e2eeSystem,
hostBridge,
autoLeaveWhenOthersLeft,
waitForCallPickup: waitForCallPickup && sendNotificationType === "ring",
// We merely sample the current mode here, so the user would need to
// manually rejoin to switch to a different one.
matrixRTCMode: matrixRTCModeSetting.value$.value,
windowSize$: scope.behavior(observeElementSize$(rootElement)),
},
reactionsReader.raisedHands$,
reactionsReader.reactions$,
scope.behavior(trackProcessorState$),
);
// TODO move this somewhere else once we use the callViewModel in the lobby as well!
vm.join();
setVm(vm);
@@ -222,8 +163,8 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
scope.end();
};
}, [
rtcSession,
client,
props.rtcSession,
props.matrixRoom,
props.muteStates,
props.e2eeSystem,
props.onLeft,
@@ -231,39 +172,10 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
hostBridge,
mediaDevices,
trackProcessorState$,
props.client,
rootElement,
rtcParticipationManager,
drivers,
]);
// Who we are, as the tiles, hands and reactions are keyed. Both drivers
// and client name the same identity; the drivers are always there.
const ownIdentifier =
drivers !== null
? `${drivers.clientDriver.userId}:${drivers.clientDriver.deviceId}`
: `${client?.getUserId()}:${client?.getDeviceId()}`;
// Reactions relate to our current membership event, wherever that lives.
const jsSdkMemberships = useMatrixRTCSessionMemberships(rtcSession);
const ownParticipationMembership = useBehavior(
rtcParticipationManager?.ownMembership$ ?? NO_OWN_MEMBERSHIP,
);
const ownMembershipEventId =
rtcParticipationManager !== null
? ownParticipationMembership?.member.eventId
: jsSdkMemberships.find(
(m) =>
m.userId === client?.getUserId() &&
m.deviceId === client?.getDeviceId(),
)?.eventId;
const reactionsTimeline = useMemo(
() =>
rtcParticipationManager === null && client !== undefined
? jsSdkReactionsTimeline(client, roomId)
: drivers!.clientDriver,
[rtcParticipationManager, drivers, client, roomId],
);
useEffect(() => {
if (vm === null) return;
@@ -273,7 +185,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
vm,
props.muteStates,
mediaDevices,
ownIdentifier,
`${props.client.getUserId()}:${props.client.getDeviceId()}`,
{ showControls: urlParams.showControls, header: urlParams.header },
);
setFooterVm(footerVm);
@@ -283,15 +195,15 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
scope.end();
};
}, [
rtcSession,
client,
props.rtcSession,
props.matrixRoom,
props.muteStates,
props.e2eeSystem,
props.onLeft,
urlParams,
mediaDevices,
trackProcessorState$,
ownIdentifier,
props.client,
vm,
]);
@@ -300,12 +212,7 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
if (developerSettingsVm === null) return null;
return (
<ReactionsSenderProvider
vm={vm}
ownIdentifier={ownIdentifier}
ownMembershipEventId={ownMembershipEventId}
timeline={reactionsTimeline}
>
<ReactionsSenderProvider vm={vm} rtcSession={props.rtcSession}>
<InCallView
{...props}
vm={vm}
@@ -317,15 +224,13 @@ export const ActiveCall: FC<ActiveCallProps> = (props) => {
};
export interface InCallViewProps {
/** The matrix-js-sdk client, when that implementation carries the call. */
client?: MatrixClient;
client: MatrixClient;
vm: CallViewModel;
footerVm: ViewModel<FooterSnapshot>;
developerSettingsVm: ViewModel<DeveloperSettingsSnapshot>;
matrixInfo: MatrixInfo;
/** The matrix-js-sdk session, when that implementation carries the call. */
rtcSession?: MatrixRTCSession;
roomId: string;
rtcSession: MatrixRTCSession;
matrixRoom: MatrixRoom;
muteStates: MuteStates;
onShareClick: (() => void) | null;
}
@@ -336,7 +241,7 @@ export const InCallView: FC<InCallViewProps> = ({
footerVm,
developerSettingsVm,
matrixInfo,
roomId,
matrixRoom,
muteStates,
onShareClick,
}) => {
@@ -505,6 +410,7 @@ export const InCallView: FC<InCallViewProps> = ({
[styles.hidden]: !showHeader,
})}
ref={headerRef}
disconnectedBanner={false} // This screen has its own 'reconnecting' toast
>
<LeftNav>
<RoomHeaderInfo
@@ -728,7 +634,9 @@ export const InCallView: FC<InCallViewProps> = ({
}
};
const rageshakeRequestModalProps = useRageshakeRequestModal(roomId);
const rageshakeRequestModalProps = useRageshakeRequestModal(
matrixRoom.roomId,
);
useAppBarSecondaryButton(
<SettingsIconButton
@@ -784,7 +692,7 @@ export const InCallView: FC<InCallViewProps> = ({
<RageshakeRequestModal {...rageshakeRequestModalProps} />
<SettingsModal
client={client}
roomId={roomId}
roomId={matrixRoom.roomId}
open={settingsOpen}
onDismiss={(): void => setSettingsOpen(false)}
tab={settingsTab}
+6 -8
View File
@@ -7,27 +7,25 @@ Please see LICENSE in the repository root for full details.
import { render, screen } from "@testing-library/react";
import { expect, test, vi } from "vitest";
import { type Room } from "matrix-js-sdk";
import { axe } from "vitest-axe";
import { BrowserRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { InviteModal } from "./InviteModal";
import { E2eeType } from "../e2ee/e2eeType";
// Used by copy-to-clipboard
window.prompt = (): null => null;
test("InviteModal is accessible", async () => {
const user = userEvent.setup();
const room = {
roomId: "!a:example.org",
name: "Mission Control",
} as unknown as Room;
const onDismiss = vi.fn();
const { container } = render(
<InviteModal
roomId="!a:example.org"
roomName="Mission Control"
e2eeSystem={{ kind: E2eeType.NONE }}
open={true}
onDismiss={onDismiss}
/>,
<InviteModal room={room} open={true} onDismiss={onDismiss} />,
{ wrapper: BrowserRouter },
);
+7 -13
View File
@@ -13,6 +13,7 @@ import {
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { type Room } from "matrix-js-sdk";
import { Button, Text } from "@vector-im/compound-web";
import {
LinkIcon,
@@ -24,29 +25,22 @@ import { Modal } from "../Modal";
import { getAbsoluteRoomUrl } from "../utils/matrix";
import styles from "./InviteModal.module.css";
import { Toast } from "../Toast";
import { type EncryptionSystem } from "../e2ee/sharedKeyManagement";
import { useRoomEncryptionSystem } from "../e2ee/sharedKeyManagement";
import { QrCode } from "../QrCode";
interface Props {
roomId: string;
roomName: string;
e2eeSystem: EncryptionSystem;
room: Room;
open: boolean;
onDismiss: () => void;
}
export const InviteModal: FC<Props> = ({
roomId,
roomName,
e2eeSystem,
open,
onDismiss,
}) => {
export const InviteModal: FC<Props> = ({ room, open, onDismiss }) => {
const { t } = useTranslation();
const e2eeSystem = useRoomEncryptionSystem(room.roomId);
const url = useMemo(
() => getAbsoluteRoomUrl(roomId, e2eeSystem, roomName),
[e2eeSystem, roomName, roomId],
() => getAbsoluteRoomUrl(room.roomId, e2eeSystem, room.name),
[e2eeSystem, room.name, room.roomId],
);
const [toastOpen, setToastOpen] = useState(false);
const onToastDismiss = useCallback(() => setToastOpen(false), [setToastOpen]);
+10
View File
@@ -27,6 +27,16 @@ vi.mock("../livekit/TrackProcessorContext", () => ({
processor: undefined,
}),
useTrackProcessorSync: (): void => {},
useBackgroundProcessing: (): { settling: boolean } => ({ settling: false }),
useAddedBackgrounds: (): {
added: [];
addBackground: () => Promise<void>;
removeBackground: () => Promise<void>;
} => ({
added: [],
addBackground: async (): Promise<void> => {},
removeBackground: async (): Promise<void> => {},
}),
}));
vi.mock("react-use-measure", () => ({

Some files were not shown because too many files have changed in this diff Show More