Compare commits

...
Author SHA1 Message Date
Timo K. 5b65ed38d2 update oxfmt to use anti-slop 2026-10-14 18:42:25 +02:00
Timo K. 344f4c087d Add anti-slot skill 2026-10-14 18:42:08 +02:00
Timo 4b73ce68e3 Merge pull request #4319 from element-hq/toger5/use-sdk
[MatrixRTCSdk] use the matrixRtcSdk
2026-10-14 10:59:05 +02:00
Timo K. d37599a794 Tag the video element with its track source for e2e specs
The SDK MediaView renders a plain video element, so LiveKit's
data-lk-source attribute is gone. Expose data-source from the SDK track
and point the screen-share spec at it.
2026-10-14 10:23:29 +02:00
Timo K. 3f98e36c56 test MatrixRTCError conversion 2026-10-14 10:23:29 +02:00
Timo K. 042a8cfbee Note the SDK coverage added on the package branch and the reconcile fix 2026-10-14 10:23:29 +02:00
Timo K. e726c36f67 Drop the auth modules main's PR 4331 added under src
The SDK replaced `src/livekit` and the remote-member modules before that PR
landed on `main`, so its `src/livekit/auth/*` and `remoteMembers/ConnectionMap.ts`
have no consumer here: the same code lives under `sdk/src/media-backend/livekit`.
The imports of them that `main` added to the view model, the developer
settings tab and the test utils go with them.
2026-10-14 10:23:29 +02:00
Timo K. dd25238427 Document how PR 4331 lands on the SDK split
Per-commit mapping of element-hq/element-call#4331 onto sdk/ and src/, the changes to api.ts, where the PR's tests went and what was checked.
2026-10-14 10:23:29 +02:00
Timo K. 432ebadd44 Key media tiles by the membership id
The bumped js-sdk reports the legacy identity as rtcBackendIdentity for every membership, so a device that rejoins after an improper leave, which has two memberships until the stale one expires, produced two tiles with the same key and crashed the call view. The membership id tells them apart; the audio renderer keys by it too.
2026-10-14 10:23:29 +02:00
RobinandJohannes Marbach 289260531b Let developers override the whole transport
The developer setting that overrode the LiveKit service URL becomes a
JSON transport, so that a `url`-only or combined transport can be tested
against a homeserver that does not advertise one yet. The settings modal
shows each LiveKit room's role instead of a never-populated isLocal flag.

Folds "Avoid null in transport dev setting" from the same PR. The setting feeds the SDK's transport option; the config fallback becomes a transport the host builds.

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-10-14 10:23:29 +02:00
Robin f97dd2adab Show the whole transport in debug UI and logs
A transport may soon carry a LiveKit SFU `url` instead of, or as well as,
a `livekit_service_url`, so a service URL alone no longer identifies it.
Pass the whole transport to the tiles' debug stats and print it as JSON in
log lines instead of picking out the service URL.

Element Call's media view models carry the member's raw transport from the SDK's TransportMetadata to the tiles.
2026-10-14 10:23:29 +02:00
Robin 1700e9fe20 Match LiveKit participants against all backend identities
A member authenticated through the legacy JWT endpoint publishes under a
`user:device` identity, while one authenticated through the default
endpoint publishes under a hashed identity. Both are listed in the
membership's backend identities, so match against the whole list rather
than only the primary one.

Element Call's test membership mock, matching the SDK's RTCMember.
2026-10-14 10:23:29 +02:00
Timo K. f638604fe7 codecov as seperate coverage figures 2026-10-14 10:23:29 +02:00
Timo K. b0659279ee update to sdk api change: audioLevel stats$() 2026-10-14 10:23:29 +02:00
Timo K. 79c1f82448 use abstracted livekti processor 2026-10-14 10:23:29 +02:00
Timo K. eaf747c107 adjust to encrypted$ -> encrypted and VideoProcessor independent of LK 2026-10-14 10:23:29 +02:00
Timo K. aac60b74dc remove waitForKeyRotationMs as its not used anymore 2026-10-14 10:23:29 +02:00
Timo K. 598ec42390 use updated state logic in the sdk 2026-10-14 10:23:29 +02:00
Timo K. cec5031f62 sdk-target-based-on-call-view-model client -> slot/participation 2026-10-14 10:23:29 +02:00
Timo K. a5a0efe92b use sdk client -> participation/slot seperation 2026-10-14 10:23:29 +02:00
Timo K. 7c5d1324e7 Update to changes in the sdk.
- plain tracks instead of media$
2026-10-14 10:23:29 +02:00
Timo K. 52aaf84c3e update EC based on sdk media refactor
No more call specifics in the sdk layer
2026-10-14 10:23:29 +02:00
Timo K. 5f3db07b76 use the matrixRtcSdk 2026-10-14 10:23:29 +02:00
Timo 10b34b0218 Merge pull request #4316 from element-hq/toger5/matrixrtc-sdk-package
[MatrixRTCSdk] add the sdk
2026-10-14 10:23:24 +02:00
Timo K. d6b8207218 Merge branch 'main' into toger5/matrixrtc-sdk-package 2026-10-14 10:00:56 +02:00
renovate[bot] 956671eef5 Update ghcr.io/element-hq/element-web:develop Docker digest to e464b2a (#4351)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-14 02:33:22 +00:00
Timo K.andClaude Fable 5.1 28e1a9e22f Keep the CI sdk package name so main's preview deploy finds it
ploy previews run pr-deploy.yaml from main, which downloads
build-output-sdk,
so a renamed artifact can never be found from a pull request. Keep the
package
name and point build:sdk:production and build:sdk:development at the
CallViewModel bundle the preview is meant to carry.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 19:04:02 +02:00
Timo K. 27dcfb8bb7 [SDK] Drop the landed plan documents and cut the migration document down
MediaBackendPlan.md and MemberMediaPlan.md described changes that have
landed. SdkMigration.md keeps only what moved from src/ into sdk/, with
the
paths as they are today. README.md gains the onboarding notes on the
three
API files, and its example uses createRTCSlot, which replaced
createMatrixRTCClient.
2026-10-13 17:10:04 +02:00
Timo K. 038991fa97 [SDK] Cover transport discovery, the join options and the room options
Unit tests for the pieces that had none: which transport the local
member advertises (override, homeserver, fallback, none), what the js-sdk
join is called with, how a publish request becomes LiveKit capture and
publish options, and the room options the connection factory builds from
the initial requests and the media quality settings, including what a
resolved connection reports while it is up. The backend's `mediaFor` gains
the cases from Element Call's member matching tests: a member on another
transport, a member changing transport, and a member without one.
2026-10-13 16:17:13 +02:00
Timo K. 2994bb4ed0 [SDK] Cover the publisher, the local media and the participation
The publisher is driven through a real LocalParticipant whose device and
signalling calls are faked, so that LiveKit's own pending-publication
bookkeeping is part of what is tested: the cases ported from Element
Call's Publisher tests (one permission prompt for both devices, nothing
created while muted, the upstream held back until the member publishes,
a mute that lands while the track is still being created) plus the
publish, unpublish and destroy paths.

The local media runs on a fake connection map: publishing before and
after the connection exists, republishing on a new connection, mutes
remembered across connections, the first publish error kept, and the
output device applied to the current room and the ones to come.

The participation runs on a fake media backend: the join options, the
state machine through connecting, connected, reconnecting, failed and
left, publishing only while joined, the delayed leave delegation, the
member ids per mode, the key replay and the data channel's sender check.
2026-10-13 16:13:47 +02:00
Timo K. d84dcc75de [SDK] Keep a value that reaches reconcile as its loop winds down
The loop that drives `scope.reconcile` was marked as running until the
promise of its last run had settled, which is a few microtasks after the
loop itself had checked for new values. A value arriving in that window
found the loop "running", was left to it, and was never processed: a
connection appearing at that moment would never get its publisher.

The flag is now cleared the moment the loop ends, so such a value starts
a new loop. The regression test drives the window microtask by microtask.
2026-10-13 16:13:47 +02:00
Timo K. 7c3f3d3ac7 Merge remote-tracking branch 'origin/main' into toger5/matrixrtc-sdk-package
# Conflicts:
#	sdk/README.md
2026-10-13 13:15:23 +02:00
Timo K. 7e4e260273 [SDK] Describe the transport auth flows in the architecture document
How a LiveKit transport is authenticated with by shape and role, the server name on connections and the options that now take a Transport.

(cherry picked from commit 26d9e1b0ae1e1f1ba8c3689bd50ba2e34aa0e820)
2026-10-13 13:00:58 +02:00
Robin 4aa249daf5 [SDK] Let developers override the whole transport
The developer setting that overrode the LiveKit service URL becomes a
JSON transport, so that a `url`-only or combined transport can be tested
against a homeserver that does not advertise one yet. The settings modal
shows each LiveKit room's role instead of a never-populated isLocal flag.

On the SDK the participation options take a whole Transport for the override and the fallback instead of two LiveKit service URLs, which also takes the last LiveKit knowledge out of transport discovery.

(cherry picked from commit d3eb40db0bcf7370418c4948049b3eac176bbdc1)
2026-10-13 13:00:58 +02:00
Robin b4bd1f81a0 [SDK] Move SFU authentication into livekit/auth and choose the endpoint by role
The OpenID flow moves to src/livekit/auth/openID.ts with the SFU config
types in a sibling file, takes an options object, and replaces the
MatrixRTC mode with the role we take on the transport plus the session's
slot ID instead of a hard-coded m.call#ROOM.

This changes behaviour: publishers on a livekit_service_url transport
always use the legacy /sfu/get endpoint, so that subscribers can predict
their user:device participant identity, and subscribers always use the
default /get_token endpoint. The MatrixRTC mode no longer influences which
endpoint is used.

On the SDK this lands as sdk/src/media-backend/livekit/auth/ with the connection map, the discriminated connection state and the client-server API flow of the following commits of element-hq/element-call#4331, which rewrite the same files:
- Model ConnectionState as a discriminated union
- Show the whole transport in debug UI and logs (SDK part)
- Let the publishing Connection authenticate itself
- Reduce ConnectionManager to a ConnectionMap
- Use the unstable LiveKit transport type
- Identify transports by server name as well as URLs
- Get SFU tokens and delegate delayed leaves through the MSC4195 CS API
- Fall back to get_token as subscriber on widget API errors
- Share a connection between homeservers using the same JWT service
- Make the auth choice mode independent again

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
(cherry picked from commit adb380cd9ed108856e42d992fc03ced8c44f1e56)
2026-10-13 13:00:58 +02:00
Robin de8b5d98ec [SDK] Match LiveKit participants against all backend identities
A member authenticated through the legacy JWT endpoint publishes under a
`user:device` identity, while one authenticated through the default
endpoint publishes under a hashed identity. Both are listed in the
membership's backend identities, so match against the whole list rather
than only the primary one.

On the SDK this is RTCMember.backendIdentities, which the LiveKit backend matches media and data packets against; the harness keys its tiles by the membership id, which the backend identity no longer tells apart.

(cherry picked from commit c9aad2a2b3279782d101c9bc6c80af0417ccbb92)
2026-10-13 13:00:58 +02:00
Johannes Marbach 7685947a69 Switch JS SDK back to develop
The js-sdk at this commit also renamed MatrixRTCSession.leaveRoomSession to leave, which the SDK follows.

(cherry picked from commit 405348945febc1df10edf9c00748f1edd6b39e71)
2026-10-13 13:00:58 +02:00
Johannes Marbach b158c4fcd7 Merge pull request #4331 from element-hq/robin/livekit-updates
Support migration to new LiveKit transport types and endpoints
2026-10-13 07:38:02 +02:00
renovate[bot] dc8f8e585a Update ghcr.io/element-hq/element-web:develop Docker digest to 06ac258 (#4349)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-13 01:36:04 +00:00
Johannes Marbach ee7dcdee7e Cover the subscriber token path, widget fallback and server names 2026-10-12 21:28:04 +02:00
Johannes Marbach 8d1ed31b0b Cover identity matching and delegation across transport shapes 2026-10-12 21:09:15 +02:00
Johannes Marbach 54e948fdc0 Make the auth choice mode independent again 2026-10-12 20:37:14 +02:00
Timo 441fe5accd Merge pull request #4348 from element-hq/toger5/room-v-12-create-room-fix
Room v12 compatibility: Dont send invalid `users` power level section.
2026-10-12 17:39:52 +02:00
Johannes Marbach 37d25d4293 Merge branch 'main' into robin/livekit-updates 2026-10-12 16:29:13 +02:00
Timo 079185b232 Merge pull request #4347 from element-hq/toger5/CI-playwright-improvements
[CI Playwirght] Make reporter more verbose + fail faster
2026-10-12 16:18:13 +02:00
Timo K. 558fbe2188 Dont send invalid users power level section. 2026-10-12 16:13:48 +02:00
Johannes Marbach 022415e98e Avoid null in transport dev setting 2026-10-12 16:11:03 +02:00
Johannes Marbach a1575cb554 Share a connection between homeservers using the same JWT service 2026-10-12 16:08:56 +02:00
Johannes Marbach 97b5e61ad8 Fall back to get_token as subscriber on widget API errors 2026-10-12 16:03:00 +02:00
Johannes Marbach f8258f6a1d Switch JS SDK back to develop 2026-10-12 15:43:44 +02:00
Johannes Marbach 68107d119e Retain compatiblity in compatiblity mode 2026-10-12 15:40:27 +02:00
Timo e96f578b63 Merge pull request #4346 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v11.2.0
2026-10-12 14:12:06 +02:00
renovate[bot] cfaae8f7e2 Update dependency @vector-im/compound-design-tokens to v11.2.0 2026-10-12 11:01:00 +00:00
Timo 1a90e813e8 Merge pull request #4338 from element-hq/toger5/compound-portal-root
[ElementCallComponent] Use compound-web's PortalRoot in EC.
2026-10-12 12:59:28 +02:00
Timo K.andClaude Fable 5.1 9d0a12c38a Fix PortalRoot hiding the component's menus and tooltips
compound-web 10.4.0 treats a PortalRoot whose root is null as one whose root
is not available yet, so floating-ui never mounts the portal and every tooltip
label, and with it every icon button's accessible name, is missing. The
component defaulted portalRoot to null and wrapped the call unconditionally.

Wrap the call only when the host supplied a root, keeping null as the
not-yet-available case Compound defines, and say so on the prop. Cover it in
CallPortalRoot.test.tsx. Drop the git allowBuilds entry for compound-web now
that it is a release again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 12:43:29 +02:00
Timo K. a1c98ac4e6 make reporter more verbose + let CI playwright fail faster 2026-10-12 12:38:10 +02:00
Timo K. b72493f47f bump compound-web to release 10.4.0 2026-10-12 11:15:03 +02:00
Johannes Marbach 49223e720b Cover url transports end to end
The specs replace the transports the e2e homeserver advertises with each
shape a migrating deployment may produce and record which token endpoints
each side used: a url-only or combined transport on both sides goes
through the CS API alone, a legacy guest joining a url host uses the
legacy endpoint to publish and the CS API to subscribe, a subscriber falls
back to the JWT service when the homeserver lacks MSC4195, a publisher
gets an error instead, a delayed leave delegated through the CS API fires
on disconnect, and subscribers get tokens for a remote homeserver's SFU
over federation.

The JWT service's app service registration has to cover users of the e2e
Synapse's real server name for the get_token flow, and lk-jwt-service
0.8.0 implements the endpoints.
2026-10-12 10:33:58 +02:00
Robin c5044119ce Deprecate the livekit.livekit_service_url config option
Homeservers should advertise their transports over the MSC4143
rtc/transports endpoint instead of relying on a fallback in the app
config.
2026-10-12 10:33:38 +02:00
Robin f366e7cbe2 Let developers override the whole transport
The developer setting that overrode the LiveKit service URL becomes a
JSON transport, so that a `url`-only or combined transport can be tested
against a homeserver that does not advertise one yet. The settings modal
shows each LiveKit room's role instead of a never-populated isLocal flag.
2026-10-12 10:33:19 +02:00
Robin e336ee5cfa Get SFU tokens and delegate delayed leaves through the MSC4195 CS API
src/livekit/auth now fronts two flows. For a transport with a LiveKit SFU
`url`, tokens come from our own homeserver's MSC4195 get_token endpoint
and delayed leaves are delegated through delegate_delayed_leave, both via
the js-sdk's unstable client methods. Transports with only a
livekit_service_url keep using the OpenID flow against the JWT service.

A transport advertising both shapes lets a subscriber fall back to the
OpenID flow when the homeserver answers get_token with a 404 (Synapse
reports M_UNRECOGNIZED for unknown routes rather than M_NOT_FOUND), so a
client can still join calls hosted on servers that have not deployed
MSC4195 yet. Publishers never fall back: a homeserver that advertises a
`url` but does not implement the endpoints is misconfigured and the
admin should hear about it, so they get NoMatrix2AuthorizationService.
Delegation uses the same flow as the token so that the participant
identity the service computes matches.

The local member only probes a transport's delegate_delayed_leave
endpoint when it has a service URL. The developer settings tab validates
a custom service URL through the same facade.
2026-10-12 10:33:00 +02:00
Robin 17c3a7cb5d Pin matrix-js-sdk to the member_id branch
Temporary, until matrix-org/matrix-js-sdk#5588 merges and this can go
back to a develop bump. The branch only changes the get_token request
body from a member object to a member_id, so this is kept separate from
the code that uses it and can be dropped without touching that code.
2026-10-12 10:32:13 +02:00
Robin 53a0e273d6 Identify transports by server name as well as URLs
A transport with only a LiveKit SFU `url` has to be authenticated through
our own homeserver, which needs to know the name of the server owning the
SFU. Carry that name alongside the transport as a TransportLocator,
derived from the member's user ID for remote transports and from the
client's domain for our own, and key the connection map on the SFU URL,
the service URL and the server name together so that `url`, service URL
and combined transport shapes are deduplicated consistently.
2026-10-12 10:31:36 +02:00
Robin 83e1396bdd Use the unstable LiveKit transport type
UnstableLivekitTransport covers every shape a homeserver may advertise
while MSC4195 evolves: a LiveKit SFU `url`, a `livekit_service_url`, or
both. Switching to it means a service URL is no longer guaranteed, so the
remaining code that relies on one checks for it explicitly: delegation
probing skips transports without one, the connection map keys transports
by whichever URL they carry, and the OpenID flow rejects transports
without a service URL until the client-server API flow lands.
2026-10-12 10:30:52 +02:00
Robin d5bbb43e3a Reduce ConnectionManager to a ConnectionMap
createConnectionMap$ now returns the behavior of connections and their
participants directly and takes an already resolved local transport
instead of an observable of it. The CallViewModel wraps it with mapScoped
so that a new map is built, in a fresh scope, whenever the local transport
changes, and swallows local transport errors there since the local member
already surfaces them in the UI.
2026-10-12 10:26:54 +02:00
Robin 24b6a632dd Let the publishing Connection authenticate itself
getLocalTransport no longer authenticates with the SFU up front and hands
the resulting config to the connection. Instead the ConnectionManager tags
the connection whose transport equals the local one as the publisher, and
every Connection fetches its own SFU config for its role.

The local member now only joins the MatrixRTC session once its connection
is authenticated, so that the LiveKit room exists before remote members
try to get a subscriber token for it. Subscribers authenticating with the
OpenID flow lack permission to create the room and would otherwise fail
without retrying, causing a cascade of leaves and rejoins at the start of
a call.
2026-10-12 10:22:33 +02:00
Robin 17c3223883 Move SFU authentication into livekit/auth and choose the endpoint by role
The OpenID flow moves to src/livekit/auth/openID.ts with the SFU config
types in a sibling file, takes an options object, and replaces the
MatrixRTC mode with the role we take on the transport plus the session's
slot ID instead of a hard-coded m.call#ROOM.

This changes behaviour: publishers on a livekit_service_url transport
always use the legacy /sfu/get endpoint, so that subscribers can predict
their user:device participant identity, and subscribers always use the
default /get_token endpoint. The MatrixRTC mode no longer influences which
endpoint is used.
2026-10-12 10:17:39 +02:00
Timo K. c79675e6a9 bump to compound-web main branch 2026-10-12 10:14:49 +02:00
Robin f260755f46 Group livekitRoomItems$ by connection
The old reducer keyed items by service URL and mutated the accumulator's
entries in place, so a transport without a service URL could not be
represented and items were not safe to compare. Group members by their
Connection object instead and carry the whole transport on the item, which
the audio renderer now receives in place of a URL.
2026-10-12 10:11:54 +02:00
Robin 3927c2b7dc Show the whole transport in debug UI and logs
A transport may soon carry a LiveKit SFU `url` instead of, or as well as,
a `livekit_service_url`, so a service URL alone no longer identifies it.
Pass the whole transport to the tiles' debug stats and print it as JSON in
log lines instead of picking out the service URL.
2026-10-12 10:09:50 +02:00
Robin 4e4ab23cda Model ConnectionState as a discriminated union
The enum mixed our own lifecycle states with a copy of livekit-client's
connection states, and errors were smuggled through the same observable as
a separate union member. A discriminated union keeps the LiveKit state
nested under "authenticated", carries the error as data, and lets the
local member represent the time before a connection exists as an
"initializing" state instead of null.
2026-10-12 10:08:22 +02:00
Robin 3f030e64dd Match LiveKit participants against all backend identities
A member authenticated through the legacy JWT endpoint publishes under a
`user:device` identity, while one authenticated through the default
endpoint publishes under a hashed identity. Both are listed in the
membership's backend identities, so match against the whole list rather
than only the primary one.
2026-10-12 10:04:47 +02:00
Johannes Marbach d8becaf868 Advertise the loopback address from the dev LiveKit SFUs
The container IP is not routable from the host when Docker runs in a VM
(colima, Docker Desktop), so calls against the dev stack drop once ICE
fails. Advertising 127.0.0.1 instead lets the browser reach the SFU through
the published ports. Firefox ignores loopback candidates, so this only
works in Chromium-based browsers.
2026-10-12 10:03:03 +02:00
Robin 994cd36c44 Fix typo in the name of the createJWTToken Playwright fixture 2026-10-12 10:03:03 +02:00
Johannes Marbach 7ac5373af9 Remove stray pause and improve logging for hung e2e tests
The list reporter is the only progress a hung CI run leaves behind, and a
global timeout under the job's 60 minute limit means a hung run still ends
with a report instead of being cancelled by GitHub.
2026-10-12 10:03:03 +02:00
renovate[bot] 32fcede65e Update ghcr.io/element-hq/element-web:develop Docker digest to d43e8d2 (#4345)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-12 02:40:40 +00:00
renovate[bot] 6438c18b73 Update ghcr.io/element-hq/element-web:develop Docker digest to 88eeb34 (#4344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-11 00:49:17 +00:00
renovate[bot] f07e9f5c70 Update ghcr.io/element-hq/element-web:develop Docker digest to 1488790 (#4343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-10 02:09:04 +00:00
Johannes Marbach c97e3bf7a6 Merge pull request #4337 from element-hq/johannes/remove-per-participant-e2ee-param
Remove perParticipantE2EE
2026-10-09 12:01:45 +02:00
Timo K. 674692c926 Fix breaking Ci tests due to compound-web 10.1.0 -> 10.3.1 bump 2026-10-09 11:09:41 +02:00
Timo K. c3d07b89bd review: unnecassary matchMedia overwrite on test start. 2026-10-09 11:08:03 +02:00
renovate[bot] f89cbc140e Update ghcr.io/element-hq/element-web:develop Docker digest to 181a526 (#4339)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-09 01:18:17 +00:00
Timo K. 3bd892ebd3 bump compound-web 2026-10-08 21:26:34 +02:00
Timo K. f322af2742 [ElementCallComponent] Use compound-web's PortalRoot in EC. 2026-10-08 19:14:01 +02:00
Johannes Marbach a9fa48f68c Remove perParticipantE2EE 2026-10-08 17:15:19 +02:00
Robin 471c76c0e7 Merge pull request #4336 from element-hq/robin/vite-warning
Fix Vite warning
2026-10-08 16:40:45 +02:00
Timo K. 799619557b [SDK] make stats$ a polled method and add audioLevel$ to audio tracks 2026-10-08 16:36:25 +02:00
Timo K. 5f914b8a01 [SDK] refactor track processor api to be lk independent 2026-10-08 16:36:17 +02:00
Timo K. aa897ae9bc [SDK] simplify public api (no lk processor, no encryption$ as obs) 2026-10-08 16:36:11 +02:00
Johannes Marbach 4881fd5fc6 Merge pull request #4280 from element-hq/johannes/claude
Add workflow for automated Claude review of pull requests
2026-10-08 15:27:13 +02:00
Johannes Marbach 49c29f160a Add workflow for automated Claude review of pull requests 2026-10-08 14:57:19 +02:00
Robin aa8f73e632 Fix Vite warning 2026-10-08 14:23:59 +02:00
Timo K. 2bb870f6e7 [SDK] fix slots missing initial status value 2026-10-08 13:53:57 +02:00
Timo K. e44dfe1898 [SDK] add codecov 2026-10-08 13:53:57 +02:00
Timo K. b5fac4a2ff Adapt to matrix-js-sdk develop and cover RTCSlot's js-sdk imports 2026-10-08 13:53:57 +02:00
Timo K. 66f326fcf0 [SDK] Update SdkArchitecture.md 2026-10-08 13:53:57 +02:00
Timo K. 3cdb452d5d [SDK] update state structure (less duplication, one state enum)
- remove media from LocalMembership file
2026-10-08 13:53:57 +02:00
Timo K. f49c1cd3d4 [SDK] rename file LocalMember -> LocalMembership 2026-10-08 13:53:57 +02:00
Timo K. dc983e5493 [SDK] split the clint object into -> slot and participation 2026-10-08 13:53:57 +02:00
Timo K. 9cc9eb0889 Create Publisher.test.ts 2026-10-08 13:53:57 +02:00
Timo K. ca4f0cdeae fixup docs 2026-10-08 13:53:57 +02:00
Timo K. 4790af08a4 flatten media api (no MemberMedia wrapper object 2026-10-08 13:53:57 +02:00
Timo K. 6ffab92f0f split api.ts into api.ts and media-api.ts 2026-10-08 13:53:57 +02:00
Timo K. f1ea4cc5a2 Merge LocalMediaInputs into MatrixRTCClientOptions
The SDK now takes `publish` requests and audio output control directly
in the main options object, removing the separate `LocalMediaInputs`
type. Audio output device selection moves from a reactive behavior to an
imperative method on `MatrixRTCClient`, giving hosts direct control over
when devices switch.
2026-10-08 13:53:57 +02:00
Timo K. 5b13e1da81 renames and restructure
- session -> matrixrtc (folder)
 - move MatrixRtcClient to top level of sdk
 - RtcTransport -> Transport
 - backend -> media-backend
2026-10-08 13:53:57 +02:00
Timo K. d0a2ff12f1 Put the media behind a backend seam and move LiveKit into it
The session speaks MatrixRTC and sees media through MediaBackend in
sdk/src/backend/api.ts. Everything LiveKit lives in
sdk/src/backend/livekit/,
the only place in the SDK that imports livekit-client; the import
boundary rule
now enforces that outside sdk/src/backend/ the LiveKit packages appear
in type
imports only.

The seam sits above the connection manager: one connection per transport
url
is the LiveKit multi-SFU strategy, not a MatrixRTC concept, so a
cascading SFU
or full mesh replaces the folder, not the session. A backend prepares
the local
transport the session discovered (the token exchange and the delegation
probe,
reported as canDelegateDelayedLeave), hands the delayed leave over per
delay
id, gives the local member one publish switch plus publish/unpublish,
builds a
remote member's media from its membership and registers that
membership's
transport for as long as the member lives, and reports its connections
for the
transport registry, which keys on the raw transport serialised with
sorted
keys. The backend's context carries no Matrix client (a backend picks
what it
calls in its own options), no membership list, and no js-sdk session:
media
keys arrive as a stream replayed on subscribe, and MatrixKeyProvider
subscribes
to it. The MatrixRTC mode no longer reaches LiveKit; it becomes a
tokenEndpoint
choice made where the default backend is built.

Two behaviour changes ride along. Publishing is one boolean, joined and
homeserver reachable, where before a sync hiccup after leave() resumed
the
upstream of tracks that were meant to stay silent. And
ResolvedConnection keeps
one ResolvedTransport object per fetch, so a state change no longer
reads as a
change of what was fetched.

MatrixRTCClientOptions gains backend?: MediaBackendFactory, defaulting
to
createLivekitBackend built from the client options.
authenticateWithTransport
takes tokenEndpoint instead of matrixRTCMode.
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 64d7eb99cc Add the media words to the architecture vocabulary
media, track, source, publish request, active and attach/detach, between
participant and transport, and the participant row names the adapter as
the only reader.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 cad00aef08 Mark the speaker detection check as fixme
Chromium's fake microphone never registers as a speaker at the SFU, and Firefox's steady tone does so in about two runs out of three, with or without the browser's audio processing. The test stays, out of the gate, until the dev SFU gets a lower speaker threshold or the harness a louder fake device.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 6bd6e3f953 Take the word call out of the member API
membership$ is typed as RTCMembership, the subset of the js-sdk CallMembership that hosts read. UnknownCallError is UnknownRTCError throughout, not only in the export. applicationData reaches the membership whole, since the js-sdk takes it next to the intent. The plan records the three slices as implemented.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 b9f79be106 Publish and unpublish on the local member
One way to put media on the session: publish(request) resolves with the local track once LiveKit has it, and unpublish(id) removes it. A request names a source with its device, processor and capture settings, so CaptureSettings on the client options goes, as do setMicrophoneEnabled and setCameraEnabled on the client and the four screen share fields on the local member; sharing is tracks$ having a screenShare track, and a capture failure rejects the promise. LocalMediaInputs becomes the list of initial requests, which also become the room defaults so that one permission prompt covers microphone and camera. The publisher keeps the requests across a reconnection and republishes what is not muted. The harness gains a Share screen button.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 5a43eaf5a6 Give the local tracks their controls
Our own tracks are LocalAudioMediaTrack and LocalVideoMediaTrack, with setEnabled and setDevice, and on video facingMode$, switchFacingMode and setProcessor. switchCamera on the local media goes, as do the device and processor behaviors in LocalMediaInputs: a host calls the track when its setting changes instead of the SDK watching behaviors it does not own. The input device ids and the processor stay as initial values for the tracks created at the join. setMicrophoneEnabled and setCameraEnabled stay for the case of no track yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K.andClaude Fable 5.1 63ccfa50be Test and document the MemberMedia track list
Unit tests for tracks$ and trackBySource$, an end-to-end check of isActive$ through a mute cycle (skipped on Chromium, whose fake microphone the SFU never hears), the architecture document brought in line with the track list, and the changed behaviour recorded in the plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:57 +02:00
Timo K. 18e0065086 specific call tracks -> generic tracks 2026-10-08 13:53:57 +02:00
Timo K. d7ab2b27b6 Create MemberMediaPlan.md 2026-10-08 13:53:57 +02:00
Timo K. 3afe52bd4a Create MediaBackendPlan.md 2026-10-08 13:53:57 +02:00
Timo K. d89c058275 Update SdkMigration.md 2026-10-08 13:53:57 +02:00
Timo K. 2af87a760d add support for sending data to the rtcSdk 2026-10-08 13:53:56 +02:00
Timo K. 5c6d2f0891 Update SDK to be consumed by Element Call's CallViewModel
Update the public API to export the primitives and operators the call
model needs, and change `LocalMediaInputs` from observable flags to
immediate booleans with imperative setters. Add session timings, media
quality and capture settings as configuration options, and allow the
host to override the transport discovery with explicit URLs.
2026-10-08 13:53:56 +02:00
Timo K. 31eafb394b 7000 loc COPIED from element call
They are copied for now but will be removed from element call once it
adopts the sdk. Those are no real line additions.
2026-10-08 13:53:56 +02:00
Timo K. 61e353d919 renames + rewriting docs 2026-10-08 13:53:56 +02:00
Timo K. 1a999eaa54 sdk implementation 2026-10-08 13:53:56 +02:00
Timo K.andClaude Fable 5.1 9910496b90 Add the MatrixRTC SDK package, its harness and a failing smoke test
Set up `sdk/` as a package shaped exactly like `component/`: its own
package.json with peer dependencies, the pnpm workspace marker so a host can
install it as a git dependency, a build tsconfig for type emission, and a vite
lib config with an externals list that `pnpm lint:externals` now checks for
both library builds.

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

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:56 +02:00
Timo K.andClaude Fable 5.1 2b9fdfe4d5 Move the SDK demo to sdk-target-based-on-call-view-model
The `sdk/` directory is about to become the MatrixRTC SDK package, so the
demo that lives there — one bundle of Element Call's CallViewModel for a plain
page or widget — moves aside, unchanged. It keeps building under a script and
vite config of its own, `pnpm build:sdk-target-based-on-call-view-model`, into
its own `dist` rather than the repository root's, and no longer copies the
developer's config.json out of `public`. Its README says which script to run
and where the output lands.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-08 13:53:56 +02:00
Johannes Marbach 14c561af8d Merge pull request #4300 from element-hq/johannes/pure-layout-functions
Make the layout decisions pure / testable functions
2026-10-08 13:20:44 +02:00
Johannes Marbach 4fd2f66949 Explain why we use merge 2026-10-08 11:14:26 +02:00
Johannes Marbach 53e038b09b layoutShallowEquals -> shallowEquals 2026-10-08 10:57:03 +02:00
renovate[bot] 3b7ccb63e2 Update ghcr.io/element-hq/element-web:develop Docker digest to 53a4fe6 (#4334)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-08 02:17:28 +00:00
Johannes Marbach 1f07f949b5 Merge pull request #4330 from element-hq/renovate/npm-postcss-selector-parser-vulnerability
Update dependency postcss-selector-parser to v7.1.6 [SECURITY]
2026-10-07 14:59:28 +02:00
renovate[bot] a60036b3d7 Update dependency postcss-selector-parser to v7.1.6 [SECURITY] 2026-10-07 12:37:14 +00:00
Timo 2eb3a2fd2d Merge pull request #4329 from element-hq/toger5/remove-lk-client-from-external-deps
Remove livekit-client from the list of external dependencies
2026-10-07 14:34:47 +02:00
Timo K. 8b6adf067e remove unnecassary (confused) comment 2026-10-07 14:20:42 +02:00
Timo K. b408610277 Remove livekit-client from the list of external dependencies 2026-10-07 12:01:21 +02:00
renovate[bot] 2faa6c3dac Update ghcr.io/element-hq/element-web:develop Docker digest to 589c490 (#4328)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-07 01:42:16 +00:00
renovate[bot] 9b3c3569ec Update ghcr.io/element-hq/element-web:develop Docker digest to 39c94f0 (#4327)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-06 02:57:18 +00:00
Johannes Marbach a4654d9d00 Merge pull request #4315 from element-hq/johannes/workflow-call
Allow workflow_call on lint and test workflows
2026-10-05 14:07:07 +02:00
Johannes Marbach 6e5f71c796 Revert back to negative flags 2026-10-05 13:53:52 +02:00
Johannes Marbach 067f3d3038 Fix repo selection and flags 2026-10-05 13:44:48 +02:00
Johannes Marbach 6d70221fe6 Flip boolean flags 2026-10-05 13:04:34 +02:00
renovate[bot] 427376f78b Update ghcr.io/element-hq/element-web:develop Docker digest to 573daa2 (#4323)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-05 09:47:57 +00:00
Johannes Marbach ccfd36923d Merge pull request #4324 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v11.1.0
2026-10-05 08:41:57 +02:00
Johannes Marbach 89567726f8 Fix tests 2026-10-05 08:13:58 +02:00
Robin 9cd111b5f2 Merge pull request #4318 from element-hq/redact-posthog-user-id
Redact analytics ID from rageshakes
2026-10-05 07:44:16 +02:00
renovate[bot] 096aaac9f3 Update dependency @vector-im/compound-design-tokens to v11.1.0 2026-10-05 01:27:09 +00:00
renovate[bot] 8835a6d75d Update ghcr.io/element-hq/element-web:develop Docker digest to 3cf23ff (#4322)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-04 09:34:33 +00:00
renovate[bot] bfb868aec3 Update ghcr.io/element-hq/element-web:develop Docker digest to 5371503 (#4321)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-10-03 01:48:09 +00:00
Robin 4e8fb6e4cb Redact analytics ID from rageshakes
To improve our minimization of external data we collect in rageshakes.
2026-10-02 16:51:00 +02:00
Robin 7c53a6be5a Merge pull request #4317 from element-hq/remove-wait-for-rotation
Remove wait_for_key_rotation_ms config option
2026-10-02 15:14:14 +02:00
Robin 64b932f128 Remove unused vite-plugin-generate-file dependency 2026-10-02 14:57:34 +02:00
Robin bfad85a3af Remove wait_for_key_rotation_ms config option
I learned that makeKeyDelay has been deprecated in matrix-js-sdk ever since the switch to the new encryption manager, so this config option has no effect.
2026-10-02 14:49:44 +02:00
Johannes Marbach be822adec8 Allow workflow_call on lint and test workflows 2026-10-02 13:33:29 +02:00
Johannes Marbach 4c0f02ef47 Merge pull request #4309 from element-hq/johannes/layered
Set up a layered build for CI similar to what EW does
2026-10-02 13:22:19 +02:00
Johannes Marbach ca6319f06d Remove layered boolean 2026-10-02 13:07:09 +02:00
Timo 1d57a8eee1 Merge pull request #4308 from element-hq/toger5/fix-component-sounds
[ElementCall Component] Decode inlined sounds directly instead of fetching them
2026-10-02 12:45:31 +02:00
Timo K. 0bb9632b60 review play sounds does not throw 2026-10-02 11:46:54 +02:00
Timo K. cbe195d449 review audio data loading 2026-10-02 11:46:40 +02:00
Robin e800fec74d Merge pull request #4314 from element-hq/renovate/element-web
Update ghcr.io/element-hq/element-web:develop Docker digest to 588f0b4
2026-10-02 11:29:05 +02:00
Robin 9d0725ad31 Fix Element Web integration tests 2026-10-02 10:51:31 +02:00
renovate[bot] 241412f29b Update ghcr.io/element-hq/element-web:develop Docker digest to 588f0b4 2026-10-02 02:54:17 +00:00
Timo K. d96d00b64a Merge branch 'main' into toger5/fix-component-sounds 2026-10-01 19:07:39 +02:00
Robin cde88a7c93 Merge pull request #4312 from element-hq/js-sdk-develop
Reset matrix-js-sdk to develop branch, adopt new join/leave signatures
2026-10-01 18:55:16 +02:00
Robin d5733e8274 Reset matrix-js-sdk to develop branch, adopt new join/leave signatures 2026-10-01 17:11:24 +02:00
Timo K. 3befe3d34b Merge branch 'main' into toger5/fix-component-sounds 2026-10-01 13:58:23 +02:00
Timo 3ad778e7e8 Merge pull request #4306 from element-hq/toger5/fix-component-tooltips
[ElementCall Component] Make compound a peer dependency with EW. (fix tooltips in embedded mode)
2026-10-01 13:57:58 +02:00
Timo K. 9a286032f2 shorten readme 2026-10-01 13:41:07 +02:00
Timo K. 363a8feab2 Stop bundling Compound, let the host supply it 2026-09-30 17:10:17 +02:00
Johannes Marbach bd8c3142a6 Set up a layered build for CI similar to what EW does 2026-09-30 11:35:06 +02:00
Timo K. 16c60c8b6b Warn when the audio context fails to set up. 2026-09-30 11:22:19 +02:00
Timo K. 63ac1ba03e Decode inlined sounds directly instead of fetching them, with the test. 2026-09-30 11:22:15 +02:00
Robin 9e190e9e57 Merge pull request #4307 from element-hq/update-js-sdk
Adapt to matrix-js-sdk removal of livekit_alias and oldest_membership
2026-09-30 10:41:37 +02:00
Robin f287fb3800 Adapt to matrix-js-sdk removal of oldest_membership support 2026-09-30 00:45:19 +02:00
Robin f9b601504b Stop sending livekit_alias in memberships
It's been many versions since anything used the value.
2026-09-30 00:45:19 +02:00
Timo K. 9140365251 Merge branch 'main' into toger5/fix-component-tooltips 2026-09-29 16:25:15 +02:00
Timo 81233e5cfb Merge pull request #4269 from element-hq/toger5/component-api-entry
Export EC component related types
2026-09-29 16:24:26 +02:00
Timo K.andClaude Fable 5.1 e296d88969 Keep the component's tooltips inside its root
Compound's Tooltip floats its bubble through floating-ui's FloatingPortal,
into the body: outside the element the component build confines its
stylesheet to, so a label tooltip shows as bare text at all times. An
enclosing FloatingPortal rooted at the container makes the nested portals
land inside it instead.

The call goes in an isolating wrapper there, standing in for the standalone
page's #root, so that its footer layer no longer paints above the modals.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-29 12:08:44 +02:00
Robin fbce73ae55 Merge pull request #4302 from element-hq/always-multi-sfu
Remove multi-SFU check which is always true
2026-09-29 09:14:42 +02:00
renovate[bot] ce79274ccc Update ghcr.io/element-hq/element-web:develop Docker digest to cbb7be9 (#4303)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-29 01:07:59 +00:00
Robin 8db2e8ac41 Remove multi-SFU check which is always true
Compatibility and Matrix 2.0 are the only MatrixRTC modes now.
2026-09-28 22:03:16 +02:00
Johannes Marbach 12352b94f5 Merge pull request #4299 from element-hq/johannes/drop-observable-hooks
Drop observable-hooks in favor of useBehavior
2026-09-28 18:12:43 +02:00
Johannes MarbachandRobin e393b6a984 Swap conditional ordering
Co-authored-by: Robin <robin@robin.town>
2026-09-28 17:56:08 +02:00
Johannes Marbach 3f63a6fc3c Drop observable-hooks in favor of useBehavior
With this change, views now read every reactive value through `useBehavior`, as
`architecture.md` already prescribes. Where a view reads a plain stream, the view
model now exposes a `Behavior`.
2026-09-28 17:54:50 +02:00
Johannes Marbach 6600c891f3 Extract the layout decision into a pure function 2026-09-28 17:36:24 +02:00
Johannes Marbach 33cfd2d8ad Build the layout media from one snapshot of its inputs 2026-09-28 17:34:22 +02:00
Johannes Marbach 5a8714805c Extract the spotlight and PiP choice into a pure function 2026-09-28 17:29:41 +02:00
Johannes Marbach 232e3f7ad4 Extract the spotlight speaker choice into a pure function 2026-09-28 17:28:14 +02:00
Johannes Marbach 10adf6658b Let layoutShallowEquals compare any layout-shaped object 2026-09-28 17:27:04 +02:00
Johannes Marbach 29742006df Move WindowMode next to the layout types to prevent cyclic import 2026-09-28 17:26:11 +02:00
Johannes Marbach addc1b10a0 Merge pull request #4297 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v10.1.0
2026-09-28 08:23:32 +02:00
renovate[bot] ee0d7dc0cb Update ghcr.io/element-hq/element-web:develop Docker digest to cfbe7ce (#4296)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-28 01:14:27 +00:00
renovate[bot] 7b1f3f28ed Update dependency @vector-im/compound-web to v10.1.0 2026-09-28 01:03:27 +00:00
renovate[bot] 8c44ecd61b Update ghcr.io/element-hq/element-web:develop Docker digest to 8301993 (#4295)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-27 01:25:15 +00:00
renovate[bot] 7d92ddf0c9 Update ghcr.io/element-hq/element-web:develop Docker digest to d97061c (#4294)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-26 02:21:38 +00:00
Johannes Marbach 5c497cc6a8 Fix build on Windows 2026-09-25 16:15:39 +02:00
fkwpandClaude Opus 5 b7bd7d18b2 [Feature] Quick audio menu (#4275)
* Give the microphone level its own observable
* Add a microphone level meter
* Show speakers and microphones in the quick audio menu
* Wire speaker selection through the call footer
* Add stories for the meter and the device menu
* Add end-to-end specs for the quick audio menu
* Keep the footer while a menu opened from it is open
* Use the shared audio capture stub in the lobby test
---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 09:52:02 +00:00
fkwpandClaude Opus 5.5 b4b72432b9 Tighten the comment guidance in AGENTS.md (#4279)
* Say what earns a comment its place

- Add a comments section to the code style: no comment by default, the reason
  only, and the kinds that go stale.
- Point to it from the PR checklist in AGENTS.md.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 08:44:04 +00:00
Robin ac1f5e2c8c Merge pull request #4277 from element-hq/vitest-warning
Fix Vitest import warning
2026-09-23 16:04:47 +02:00
renovate[bot] 98d77cc816 Update ghcr.io/element-hq/element-web:develop Docker digest to 0636bbe (#4276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-23 01:55:54 +00:00
Robin d5812ddd34 Fix Vitest import warning
Fixes the following warning:

[warn] (!) Your Vite config uses features that are unsupported by `configLoader: 'native'`, which is planned to become the default in a future major version of Vite:
  - import "./vite.config" without a file extension (vitest.config.ts:3:35). Add the file extension
Set `VITE_CONFIG_NATIVE_IGNORE_WARNING=true` to suppress this warning.
2026-09-22 23:34:19 +02:00
Johannes Marbach cf7c275fb7 Merge pull request #4274 from element-hq/hs/bell
Bell reaction sound.
2026-09-22 12:32:38 +02:00
Half-Shot 93b2661513 Switch to 🛎️ 2026-09-22 11:21:11 +01:00
Robin 1f84cb9c5c Merge pull request #4273 from element-hq/simplify-local-transport
Simplify local transport code
2026-09-22 12:15:34 +02:00
Half-Shot 4269aaa163 update snap 2026-09-22 10:04:12 +01:00
Half-Shot bc62bb9565 lint 2026-09-22 10:01:22 +01:00
Half-Shot be56bbd05b lint 2026-09-22 10:01:10 +01:00
Half-Shot eb44fd545d Bell reaction sound. 2026-09-22 09:57:43 +01:00
Robin c9f911b7c9 Swallow local transport errors in connection manager 2026-09-22 10:01:14 +02:00
Robin 3a189d155b Remove references to outdated variables or types 2026-09-22 09:56:02 +02:00
Robin 0211d5c367 Remove unused shareReplay operator 2026-09-22 09:45:48 +02:00
Robin c88f884fc9 Simplify local transport code
As of the removal of 'Legacy' mode, there can no longer be a difference between the transport you advertise in your membership and the transport you publish media on, so the local transport code can be simplified considerably. For instance, the function which gets the local transport can simply return a promise rather than being reactive.

I kept the local transport as an Observable in other modules so that they could easily be tested with existing marble tests.
2026-09-21 12:51:28 +02:00
Johannes Marbach 9f7c35cd8f Merge pull request #4270 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-design-tokens to v11
2026-09-21 07:51:35 +02:00
renovate[bot] ac81a7c0ab Update dependency @vector-im/compound-design-tokens to v11 2026-09-21 02:56:00 +00:00
Timo K. 8ebb0b5a90 Export component related types. 2026-09-19 08:46:30 +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
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
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
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
Johannes Marbach fe91162869 Merge pull request #4257 from element-hq/matthew/log-livekit-ice
Always log LiveKit ICE candidates and states to the rageshake
2026-09-14 08:31:42 +02:00
Johannes Marbach 74328d7e42 Merge pull request #4258 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.22.3
2026-09-14 08:03:12 +02:00
renovate[bot] 6046337877 Update dependency livekit-client to v2.22.3 2026-09-14 02:37:28 +00:00
Matthew Hodgson 16b2c31ebb Always log LiveKit ICE candidates and states to the rageshake
LiveKit's livekit-ice logger records each local ICE candidate, ICE
candidate errors and ICE connection state changes, but only at debug,
which we cap to info unless extended LiveKit logs are switched on. That
means a 'could not establish pc connection' rageshake carries no way to
tell 'no relay candidates at all' from 'relay candidates that never
paired'. Keep that one logger at debug regardless of the toggle.
2026-09-11 18:30:33 +01:00
fkwpandClaude Opus 5 a58a5f615f Add AGENTS.md documenting the repo's conventions (#4256)
* Add AGENTS.md documenting the repo's conventions

Codifies what is currently tribal knowledge or only discoverable by reading CI
config: the view model / view contract, the rule that nothing reads the page,
Clean Code ordering, the three test layers, and reuse-before-you-build.

The root AGENTS.md is the always-loaded minimum and routes to docs/agents/, so
an agent loads only the detail its task needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Allow either hand at the commit, and measure continuous redraws

Committing from the agent rules out running it where there is no git
identity, a sandbox included. Either is now fine: commit on the user's
word where the setup allows it, or write the message and hand it over.

Redrawing continuously is the other addition. A level meter reported a
fresh value every animation frame, so its whole menu reconciled sixty
times a second, silence included, and nobody noticed until review. A PR
that adds anything of the kind now says what it costs, or better, counts
the redraws — commits rather than render calls, since React runs a
component it then discards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 15:12:15 +02:00
Robin 2cac2d7e8b Merge pull request #4231 from element-hq/matthew/blur-processor-ended-track
Don't attach the background blur processor to an ended camera track
2026-09-10 21:27:11 +02:00
Robin 1695d3f518 Use flushPromises for readability 2026-09-10 21:15:48 +02:00
Robin 96a2e9b481 Merge pull request #4230 from element-hq/matthew/connection-stop-during-connect
Don't report an aborted connect as a connection error
2026-09-10 20:25:12 +02:00
Robin 23ba8f3204 Merge pull request #4249 from element-hq/matthew/audio-renderer-warn-spam
Stop MatrixAudioRenderer warning on every render
2026-09-10 19:56:56 +02:00
Matthew HodgsonandClaude Fable 5.1 b284bb00c0 Cover the stopProcessor failure path and trackProcessorSync
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GSB8Qb89KDoTNRV83dWJHa
2026-09-10 18:28:57 +01:00
Johannes Marbach cb0ae89143 Merge pull request #4253 from element-hq/johannes/jwt-0.7.0
Bump lk-jwt-service to 0.7.0 in the docker setup and integrate it as an app service
2026-09-10 17:46:02 +02:00
Johannes Marbach 344aac21f9 Force undelegated mode in tests 2026-09-10 14:15:38 +02:00
Johannes Marbach 21d7f24f61 Bump lk-jwt-service to 0.7.0 in the docker setup and integrate it as an app service 2026-09-10 12:26:49 +02:00
Johannes Marbach 029622f07b Merge pull request #4251 from element-hq/renovate/npm-postcss-selector-parser-vulnerability
Update dependency postcss-selector-parser to v7.1.3 [SECURITY]
2026-09-10 07:42:07 +02:00
renovate[bot] 251535d371 Update dependency postcss-selector-parser to v7.1.3 [SECURITY] 2026-09-09 17:42:33 +00:00
Timo 5c6fa7c864 Merge pull request #4233 from element-hq/valere/component_ec_M1
ElementCall as a react component: refactor + a local dev harness
2026-09-09 19:39:30 +02:00
Timo K. be4023dd66 fix layout in spotlight (top inset) 2026-09-09 19:20:31 +02:00
Timo K. be5f3390f7 more tests 2026-09-09 17:01:16 +02:00
Timo K. 59ef5f5400 ci 2026-09-09 16:05:18 +02:00
Matthew Hodgson dbd2423e7b Stop MatrixAudioRenderer warning on every render
The "has no matching matrix call member" warning fired on every re-render
(i.e. every active speaker update) and also for the local participant's own
track, which is never rendered here anyway. Skip local tracks and warn once
per unexpected identity.
2026-09-09 11:20:49 +01:00
Timo K. 2ac95df75f update readmes 2026-09-09 12:15:19 +02:00
Timo K. 7821b465a5 Merge branch 'main' into valere/component_ec_M1 2026-09-09 10:23:47 +02:00
Robin b90ed5bdf7 Merge pull request #4247 from element-hq/fix-cd
Fix call.element.dev not updating after branch rename
2026-09-09 09:07:51 +02:00
Robin 1d7e2288f7 Fix call.element.dev not updating after branch rename 2026-09-09 08:49:08 +02:00
Robin 61eeb8171a Merge pull request #4242 from element-hq/delayed-leave-timings
Adapt delayed leave timings when delegation is available
2026-09-09 08:19:58 +02: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
Robin cff8766aa6 Delegate delayed leaves in LocalMember rather than LocalTransport
IMO this is where the delegation calls should have lived all along, since the leave event is part of the membership lifecycle, and we otherwise end up with an awkward hack to ignore transport updates.

Doing this now ensures that the client won't send any delegation requests if delegation is unsupported, and prepares the code for a future change in which we use the dedicated delegation endpoint from the CS API.
2026-09-08 21:22:52 +02:00
Robin 5cfe7ea4df Base JWT service endpoint choice directly on the MatrixRTC mode
Since we removed the extra 'legacy' mode, meaning there's nothing simplified anymore by translating things over to a JWT service version.

(Also, simplify some things by making the mode static for the duration of a call.)
2026-09-08 21:22:52 +02:00
Robin d38daef567 Reflect session config always being defined 2026-09-08 21:22:52 +02:00
Robin 09782a4d84 Give JWT token fetch procedures more appropriate names 2026-09-08 21:22:52 +02:00
Robin 839e36abd4 Make delegation support check fail immediately in case of CORS errors 2026-09-08 21:22:52 +02:00
Timo K. 3485ce47bc Merge branch 'main' into valere/component_ec_M1 2026-09-08 20:05:27 +02:00
Timo K. 273ee3b632 add allowJoinUnmutedViaIntent to the bridge
This allows us to control to never start unmuted in spa but be able to
start unmuted in widget and component mode.
2026-09-08 18:12:40 +02:00
Timo K.andClaude Fable 5.1 486ecbfda0 Stop offering media downloads on the component's host bridge
`downloadMedia` exists because a widget's client cannot fetch media: it
has no access token, and its only route to a file is MSC4039 over the
widget API. A component host hands over a client that does hold
credentials, so Element Call fetches media with it and the callback was
never needed there. It comes off `ElementCallHostBridge`, leaving a host
with only what it must understand, and stays on the internal HostBridge
for the widget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 17:14:55 +02:00
Timo K.andClaude Fable 5.1 b722cc277e Make the theme a prop, next to the language
The theme is state — what Element Call should look like right now — and
so belongs beside `language` as a prop, not on the imperative handle
(where it was a request, `setTheme`, because the internal host bridge
speaks the widget API and a widget's host sends theme changes as
requests) and not in the configuration (where `config.theme` only ever
set the starting theme).

The `theme` prop feeds the same channel the rest of Element Call listens
to for a host's theme, replayed so that whatever subscribes after the
host has set it still hears the current one. Changing it re-themes the
container and nothing else; unlike the language, it is per component.
`setTheme` and `config.theme` are gone, and the harness gets a theme
picker in place of its per-pane buttons.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 17:02:24 +02:00
Timo K.andClaude Fable 5.1 f994586eeb Speak every language in the component, not just English
The component bundled English alone: the standalone app fetches its
locale files at runtime from URLs its own build emits, which a host
serving the library from elsewhere could not resolve, so bundling one
language was the self-contained option. Now every locale is a chunk of
its own that the host's bundler loads the first time it is needed, with
English still bundled in so that the fallback never waits.

Element Call starts in the browser's language and follows the host's own
setting through a `language` prop; `supportedLanguages` says what it
accepts. Translations are shared by every Element Call on the page, so
the most recently set language wins for all of them. The harness gets a
language picker, and the app and the component share the parsing of
locale paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 16:03:48 +02:00
Timo K.andClaude Fable 5.1 b7285064fd Let the shell say where home is, and drop the router from the call
The call reached for react-router in five places to send the user
"home": on leaving without a lobby, from the lobby's recents link, from
the post-call screen, from the error page's return button and from the
header logo. Home is the standalone app's home page; the call has no
idea where that is, and a component has no such place at all — its host
decides what follows a call. Yet the component had to mount a
MemoryRouter just so those hooks would not throw.

`useLeaveToHome` is the way home as the shell supplies it: the app
provides `navigate("/")` from inside its router, the component provides
nothing, and everything that used to link to "/" now either calls it or,
when there is none, offers no way out. The logo becomes a plain logo,
the recents and "not now" links disappear, the error page's button does
too. `ClientProvider`'s logout goes the same way. The component no
longer renders a router.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:39:00 +02:00
Timo K.andClaude Fable 5.1 f5a94ce702 Offer a component host only the configuration it can use
`ElementCallConfiguration` was every URL parameter, which put the widget
plumbing (widgetId, parentUrl, isWidget), the user's identity, the
homeserver, the analytics and Sentry settings, and the standalone app's
shared room secret in front of a host that has no business setting any
of them — the component reads none of those. It is now the behavioural
configuration a widget's URL can carry plus the two properties a host
has a say in: the theme and the background. The background is now
actually applied to the component's root, which it was not before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:39:00 +02:00
Timo K.andClaude Fable 5.1 3dc7cfe10b Say "component" for the component
"Embedded" already means something here: the embedded package is the
widget build. Where a comment meant Element Call running as a React
component inside a host application, it now says so, and the params it
starts from are `componentProperties`. "Host" stays the word for the
application on the other side, whether that is a widget container or an
application rendering the component.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:39:00 +02:00
Timo K.andClaude Fable 5.1 f70fba1dd6 Rename GroupCallView to CallView, and fold ElementCallView into it
Three layers stood between a shell and the call: `ElementCall` (the
component), `ElementCallView` and `GroupCallView`. The middle one only
held the `joined` flag and the mute state, both of which nothing outside
the call reads any more, so it is folded into the view it wrapped. That
view is now `CallView`, since "group call" is a name from before
Element Call handled anything else.

Its docstring says what it is: the whole lifecycle of a call — lobby,
the call itself, and what comes after — with the lobby and the post-call
screen each present or skipped depending on the parameters and the host.
Both the standalone RoomPage and the component render it directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:38:59 +02:00
Timo K.andClaude Fable 5.1 8e8bc5ddde Talk to a component host in callbacks, not observables
The component exposed the internal HostBridge to hosts as-is, which
carried the host's requests as rxjs observables. That made rxjs part of
the public API of a package that bundles its own copy of it, so a host
would build bridges with a different rxjs than the one Element Call
consumed them with — and asked every host to learn rxjs to change the
theme.

A component host now implements plain async callbacks for what Element
Call tells it (`ElementCallHostBridge`, all optional), and makes its own
requests through an imperative handle on the component's `ref`
(`ElementCallHandle`: setTheme, join, hangUp, setDeviceMute), each
resolving once Element Call has acted and rejecting when nothing in
Element Call can. `component/host.ts` adapts that to the HostBridge the
rest of Element Call still speaks, with a bridge whose identity never
changes, so a host re-creating its callbacks on render restarts nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:38:59 +02:00
Timo K.andClaude Fable 5.1 d18e82c546 Catch the component's errors inside the component
Only the call body sat inside an error boundary; anything thrown in the
lobby, the mute state or the providers propagated into the host's React
tree, where React unmounts everything up to the nearest boundary — the
host's, or the host's whole application. The standalone app has a
boundary at its top; the component now has the same, showing the error
page inside its container and asking the host to stop keeping it on
screen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:38:59 +02:00
Timo K.andClaude Fable 5.1 779ee7a008 Finish measuring the root rather than the viewport
The move from media queries to `@container element-call` queries left a
few places still asking the viewport: the room header's compact size
and the lobby's footer placement went through `useMediaQuery`, the
lobby's video preview was `50vh` tall, the reaction picker was capped at
`100vw`, and the content insets and the gradient background were sized
from `100vw`/`100vh`. Embedded in a corner of a host's page, each of
those answered for the page rather than the corner.

`useRootSizeMatches` is `useMediaQuery` for the root element, built on
the same `observeElementSize$` the layout uses; the lengths become
container units. Container units resolve against the nearest query
container, and there are others in the tree (the spotlight layouts, the
media tiles), so base.css says when they may be used.

jsdom gives the body no size at all, which would have every such query
read as a tiny window; the test setup now gives it a desktop's, matching
what the media query mock already answered.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:38:59 +02:00
Timo K.andClaude Fable 5.1 7cd21476f7 Say outright whether the profile is ours to change
Whether to offer the profile settings was inferred from whether the host
could close Element Call. For a component with no host bridge — the
default — nothing could, so an embedded Element Call let the user edit
the profile of an account that belongs to the host application.

`HostBridge.supportsProfileChanges` states it directly: true standalone,
where Element Call signed the user in itself; false for a widget's host
and for anything embedding the component (which sets it itself, since
the client it hands over is its own). The profile tab and the profile
shortcut follow that. What a host's ability to close us still decides —
what to show after the call ends — is a question about who owns our
lifetime, and stays keyed on `close`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:38:59 +02:00
Timo K.andClaude Fable 5.1 f0359259f9 Format base.css
The formatter wants the layered import of the Compound design tokens on
one line; "de-globalise styles" left it wrapped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 15:38:59 +02:00
Timo K.andClaude Fable 5.1 7137e6ec14 Update the developer settings snapshot for the scoped pre style
"de-globalise styles" gave the tab's <pre> elements a class instead of
styling the bare element, but did not update the snapshot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:56:10 +02:00
Timo K.andClaude Fable 5.1 6ae4f0539a Leave the page title to the page
The call and the lobby each set `document.title`, so a component
embedded in a host renamed the host's tab to "Element Call | <room>".
The title belongs to whoever owns the page: the standalone app's
RoomPage now sets it, for whichever room it has got as far as knowing
about, and the call itself no longer touches it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:53:50 +02:00
Timo K.andClaude Fable 5.1 bc58aed0d7 Keep keyboard shortcuts within Element Call's root
The call's shortcuts were listened for on the window and allowed
whenever focus was inside the standalone app's `#root` — which, for a
component embedded in a host, is the host's own root, or nothing. So m,
v and space fired while the user typed in the host's composer, and two
Element Calls on one page both answered every key.

Listen on the element Element Call treats as its root instead (the body
standalone, so nothing changes there), and judge whether a key press is
spoken for by what has focus — a dialog or a text field — rather than by
where it sits in the DOM, since the modals are now portalled to that
same root.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:52:34 +02:00
Timo K.andClaude Fable 5.1 608f107f8d End the component's media device scope on unmount
The component built its `MediaDevices` once, in a scope nothing ever
ended, so every mount left device observers running for the rest of the
page's life. Building it in an effect ties the scope to the component's
lifetime — and to the options it was built with, which were previously
frozen at first render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:50:17 +02:00
Timo K.andClaude Fable 5.1 a010cc983f Compare the component's config by value
Everything downstream of the component's params — the mute state, the
call view model and with it the media connection — is keyed on the
identity of the params object, which was memoised on the identity of
the `config` prop. A host writing `config={{ ... }}` inline, which is
the natural way to write it, therefore tore the whole call down on
every render. The harness happened to pass a constant, so nothing
noticed.

`useStableValue` hands out the same object for as long as a deep
comparison says nothing changed, so an inline config costs nothing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 13:49:19 +02:00
Johannes Marbach 00d0adc3c4 Merge pull request #4222 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-09-08 11:40:20 +02:00
Johannes Marbach ddc5a50529 Disable new react compiler rules 2026-09-08 11:29:36 +02:00
renovate[bot] 27734a1a02 Update all non-major dependencies 2026-09-08 06:13:17 +00:00
Timo K. 489c1af426 test resizing via @container 2026-09-07 17:35:25 +02:00
Timo K. 365204d04c fix media queries in component based element call. 2026-09-07 14:50:36 +02:00
Johannes Marbach 14c61db43d Merge pull request #4241 from element-hq/matthew/encryption-mismatch-logging
Log track encryption flag and cryptor events for remote tracks
2026-09-07 13:30:50 +02:00
Johannes Marbach 65e1b845a7 Apply suggestion from @Johennes 2026-09-07 13:18:00 +02:00
Robin 6a9583a2ce Adapt delayed leave timings when delegation is available
Splits the config options for the timings of a delayed leave event into two sets: one for when delegation is available (as you can relax the timings and get more stable calls this way), and another for when it's unavailable (as we must continue to gracefully downgrade even after Matrix 2.0 is fully rolled out).

This works by bluntly hitting the delegation endpoints without auth before joining to check for a 404.
2026-09-07 13:10:54 +02:00
Matthew Hodgson eddd95ba41 Log track encryption flag and cryptor events for remote tracks
Extends the [RemoteTracks] logging from #4235 with the per-publication
encryption flag and the ParticipantEncryptionStatusChanged /
EncryptionError room events.

If a publisher encrypts frames while the subscriber believes the
publication is unencrypted, livekit-client bypasses the cryptor and hands
raw ciphertext to the decoder, which is audible as loud noise bursts.
The reverse mismatch (or a missing/invalid key) drops frames instead.
Neither case is visible in a rageshake today.
2026-09-07 11:53:01 +01:00
Johannes Marbach a376280c91 Merge pull request #4240 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.22.2
2026-09-07 08:07:34 +02:00
renovate[bot] 1e9a5cfa20 Update ghcr.io/element-hq/element-web:develop Docker digest to 6ed62b2 (#4239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-07 02:04:08 +00:00
renovate[bot] d204b36b55 Update dependency livekit-client to v2.22.2 2026-09-07 01:56:21 +00:00
renovate[bot] a03f23e720 Update ghcr.io/element-hq/element-web:develop Docker digest to a4e9c8c (#4238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-06 02:02:35 +00:00
renovate[bot] 210d94af18 Update ghcr.io/element-hq/element-web:develop Docker digest to 3221e92 (#4237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-05 02:55:22 +00:00
Timo K.andClaude Fable 5.1 218ee46b7d Make the component installable as a git dependency
`component/package.json` describes the built component as a package
(`@element-hq/element-call-component`: entry, stylesheet subpath, types,
peer dependencies) so that a host can depend on
`github:element-hq/element-call#<ref>&path:/component`. Its `prepare`
script builds on install, since nothing is published yet.

For that the component build now lands in `component/dist` instead of the
repository's `dist`, and `pnpm build:component` also emits the type
declarations (`component/tsconfig.build.json`, `build:component:types`),
which previously had to be produced by hand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 15:29:55 +02:00
Timo K. 38c28a1c8f scaling based on component size instead webview size 2026-09-04 14:16:40 +02:00
Timo K. f5458bb03e de-globalise styles 2026-09-04 13:51:47 +02:00
renovate[bot]andRobin 70f6e785ce Update ghcr.io/element-hq/element-web:develop Docker digest to 724b468 (#4226)
* Update ghcr.io/element-hq/element-web:develop Docker digest to a9bd290

* Fix Element Web integration tests

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
2026-09-04 09:18:19 +00:00
Johannes Marbach 0ad31cfc4d Merge pull request #4232 from element-hq/matthew/screenshare-watchdog
Surface screen share failures instead of silently doing nothing
2026-09-04 07:54:23 +02:00
Johannes Marbach bae8da3ddc Reformat 2026-09-04 07:44:16 +02:00
Johannes Marbach b22eba7af9 Add further tests 2026-09-04 07:39:58 +02:00
Robin cd7510e054 Merge pull request #4235 from element-hq/matthew/remote-track-logging
Log remote participant and track events per LiveKit connection
2026-09-03 20:22:27 +02:00
Robin 054913d17d Use testScope in ObservableScope tests 2026-09-03 20:11:18 +02:00
Matthew Hodgson dee4b05695 Diagnostics for duplicate participant matches and mid-delivery scope teardown
Warn when ConnectionManagerData merges participants from a second
connection to the same SFU URL, when more than one LiveKit participant
matches a member's backend identity, and when a scope is ended while one
of its behaviors is still delivering a value (which strands the later
bound subscribers on the old value). The per-member match log now also
names the connection the participant came from.
2026-09-03 18:43:18 +01:00
Matthew Hodgson b1393998a7 Report re-entrancy depth and tag torn behaviors
A single re-entrant emission can only strand a contiguous run of a
behavior's subscribers, so a tile that is both 'speaking' and 'waiting
for media' (a middle subscriber stranded) implies a nested re-entry.
Report the nesting depth and log each new depth once instead of only the
first re-entry, and tag splitBehavior-derived behaviors with their field
name so the warning identifies which behavior tore.
2026-09-03 18:08:53 +01:00
Matthew Hodgson 2ab2197c01 Warn when a behavior is re-entered while delivering a value
rxjs delivers a nested emission to every subscriber and then resumes
delivering the outer, older value to the remaining subscribers, which
leaves them permanently out of sync. Log the first occurrence per
behavior with a stack trace so the re-entrant path can be identified
from a rageshake.
2026-09-03 17:26:38 +01:00
Valere ace78de749 Cover Element Call as a component with end-to-end tests
The widget tests cannot reach what makes a component different, because the
iframe used to guarantee it: that Element Call stays inside the space it was
given, and that two of them can exist in one page. So the development harness
gets driven by Playwright.

Three tests. Two components in one page holding a real call between two devices
of one account. The settings dialog and the reaction picker staying inside the
container the host gave, asserted by bounding box. And the host bridge
reporting in both directions, including a host-initiated mute coming back as a
report of the new state.

The containment test is the one worth having: both of the escapes found by
running the harness by hand — the settings dialog centred on the window, and
the reaction picker at 82vh landing below the container — would have failed it,
and neither was visible to typechecking, linting or the unit tests.

Users and rooms are created through the Synapse admin and client-server APIs
rather than by driving an interface, and the harness now takes its credentials
from its own query string so that a test can say which account and room to use.
A host reading its own URL is proper; it was Element Call doing so that was the
mistake.

Playwright gains a second web server for the harness on port 3001, a Vite dev
server whether or not the app itself is served from Docker, since the harness is
a development page with nothing to build.
2026-09-03 18:09:12 +02:00
Matthew Hodgson 0b95fd5a65 Log remote member participant matching and tile waiting state
A tile shows "Waiting for media" for as long as its MatrixRTC member
cannot be matched to a LiveKit participant. Log each transition of that
match and of the tile's waitingForMedia state so rageshakes can tie a
stuck tile to the LiveKit participant and track events.
2026-09-03 17:07:08 +01:00
Matthew Hodgson f8f5caaa09 Log remote participant and track events per LiveKit connection
Rageshakes contained nothing about the state of remote tracks, so a tile
showing the wrong mute or video state for a member could not be
diagnosed. Log connect/disconnect, publish/unpublish, subscribe/
unsubscribe, subscription failures, remote mute/unmute and stream state
changes on each Connection's logger, and remove the listeners when the
connection scope ends.
2026-09-03 16:50:30 +01:00
Valere 3361ce2b60 Keep what the component draws inside the container it was given
Opening settings in an embedded call put the dialog in the middle of the host's
window, spilling outside the container, and with two calls on a page the second
drew over the first's dialog.

The container established a stacking context but not a containing block, which
are two different things and only the first had been done. `position: fixed`
resolves against the viewport unless an ancestor makes itself the containing
block, so the modal scrim and dialog in Overlay.module.css — `fixed`, `inset:
0`, centred, because in the standalone app they are meant to cover the page —
were positioned and sized against the window. Layout and paint containment
makes the container the containing block for those descendants and clips what
we paint to our own box. The second-call-on-top symptom goes with it, since the
dialog now stays inside the first call's box and there is nothing to overlap.
Two sibling components still cannot draw over one another by construction,
neither being able to leave its own stacking context, but that only shows if a
host overlaps them.

Containment clips a box measured in viewport units but cannot resize it, so
anything positioned that way is simply put somewhere outside the container and
disappears. That applied to the reactions overlay, a `100vw` by `100vh` box, and
to the reaction picker, which sits at `82vh` so as to appear near the footer it
belongs to. Both are positioned against whatever Element Call treats as its
root — `[data-overlay-container]` in the app, which is the size of the page, and
the host's container when embedded — so percentages mean the same thing there
and the right thing here. The earpiece overlay is `inset: 0` with no viewport
units, so containment is enough for it.

Three viewport-relative sizes remain, all of which need more than a change of
unit: the lobby's video preview is `50vh` on a flex item with an aspect ratio,
`--content-inset-*` ramps up to a desktop inset from the window width, and the
picker's `max-width` cap is the window's.

The clipping cuts both ways: a menu near the edge of a small container is
trimmed rather than overflowing into the host. That is the trade an embedded
component makes.
2026-09-03 17:30:16 +02:00
Valere c0a54dcaf9 Derive the component's defaults from an intent, not the host's URL
The component seeded its parameters with `computeUrlParams()`, which reads
`window.location`. Element Call is not the page any more, so what it found
there was the host's URL: no `widgetId`, therefore not a widget, therefore no
intent, therefore the standalone app's preset.

The visible symptom was the Element Call logo in the footer of a call embedded
in someone else's application, since `showLogo` follows `header ===
HeaderStyle.Standard`. The rest of that preset mattered more. A hosted call
defaulted to `perParticipantE2EE: false` and `confineToRoom: false` — so
unencrypted, and willing to take the user out of the room a host had put them
in — and a host could not correct either without naming every parameter
itself, nor even name `header`, since the enums were not exported.

So the intent presets move out of `computeUrlParams` into
`configurationForIntent`, and the component builds its parameters from an
intent plus the properties a host has no URL to supply. `intent` becomes a
prop, defaulting to joining an existing group call: the lobby first, confined
to the room, encrypted per participant, and no Element Call branding in
someone else's interface. A host that knows which button the user pressed
should say which.

One deliberate difference from the widget presets: the background defaults to
solid rather than the gradient, which is drawn by a `position: fixed`
pseudo-element and would escape the container to cover the host.

`UserIntent.Unknown` still means the standalone preset, so the app and widget
are unchanged — including a widget that names no intent, which has always been
given those defaults.
2026-09-03 16:39:14 +02:00
Valere 9f5fa6049f Add a harness for Element Call embedded as a component
`pnpm dev:component` serves a page that stands in for a host application: it
signs in twice against the development backend and shows two calls side by
side, in resizable boxes, with furniture of its own around them. Two devices of
one account, so a real call happens between the two components and anything
Element Call keeps once per process rather than once per call shows itself.

The host bridge is driven by hand and reports both directions in a log along
the bottom, which is the first exercise the theme, hang-up and device-mute
requests have had outside widget mode. Each pane can be unmounted and remounted
to see what Element Call leaves behind, and there is a `position: fixed` dialog
belonging to the host to see whether it covers the calls. The page uses none of
Element Call's design tokens, so anything that looks styled outside a pane came
from Element Call reaching out of its container. It reaches Element Call only
through the component's public interface, which is how the exports missing from
that interface came to light.

Three things about the component build the harness turned up on the way, all
too small to be worth their own commits:

- It copied `public/` into `dist/`, including the developer's own gitignored
  config.json, into output we would publish. `publicDir: false`, as the
  embedded build already does. The sdk build has the same leak; untouched.
- `pnpm lint:externals` now exists, which the build config already claimed it
  did. It reads the external list out of that config and fails if the source
  imports React, the Matrix SDK or LiveKit by a path the list does not name.
  Since the bundler silently ignores the pattern form of that option, an
  unnamed subpath is bundled with no warning at all — which is how a host would
  end up with a second React.
- `lint:oxlint` ran over `src playwright`, so nothing in `component/` had ever
  been linted.

Serving a page also meant the shared plugin list could no longer inject the
app's HTML entry point unconditionally, so that is now optional — and off for
the library build too, which never had an HTML page to inject it into.
2026-09-03 16:11:39 +02:00
Valere db0f6837ce Give the component what the app shell was providing
The component built and typechecked in the previous commit, but only because
nothing had rendered it. Everything Element Call needs that `src/main.tsx`
side-loads was missing from it.

Its stylesheet: only main.tsx imported index.css, so the library build emitted
CSS-module styles with every `--cpd-*` and `--font-size-*` unresolved. Split
into base.css, which both the app and the component import, and the rules that
are about owning a page, which only the app does. The split is a straight move
— comment-stripped and sorted, the old file and the two new ones differ by
exactly one line — and that line is the deliberate part: `.no-scroll-body`
becomes `body.no-scroll-body`. Element Call adds that class to whatever it
treats as its root, and since the root can now be a container, `position:
fixed` would have taken that container out of the host's layout. Pinning the
page is what it always meant.

Its translations: `initializeElementCall` called `i18n.init` with neither
resources nor a backend, so every key would have rendered as itself. English is
bundled in. The app fetches locale files from URLs its own build emits, which a
host serving the library from somewhere else could not resolve, so how a host
picks a language is left open.

And the types a host needs: `HostBridge` alone is not enough to implement
`HostBridge` — `HostRequest`, `DeviceMuteState`, `DeviceMuteRequest` and
`JoinCallData` all appear in its signatures, and `ConfigOptions` in
`initializeElementCall`'s.
2026-09-03 16:10:26 +02:00
Matthew Hodgson bf5482db8c Surface screen share failures instead of silently doing nothing
toggleScreenSharing only had `.catch(logger.error)`, so a getDisplayMedia
request that hangs (element-call-rageshakes#17152: Element Desktop on
Windows, the user pressed the screen share button 14 times in 25 seconds
and the log shows nothing but the toggle lines and livekit-client's
"waiting for pending publication promise timed out") left the user with
a button that does nothing and us with no evidence of why.

Log when a toggle is requested and when it completes or fails, with the
elapsed time, so a hang is visible in the logs. Explicit failures other
than the user cancelling the picker show as a non-modal "Could not start
screen sharing" toast. Nothing is inferred from a toggle taking a long
time: the user may simply be choosing what to share.
2026-09-03 15:01:35 +01:00
Matthew Hodgson 2e5b5cb186 Don't attach the background blur processor to an ended camera track
If the camera track has already ended by the time the blur processor is
applied to it, MediaStreamTrackProcessor cannot be constructed and
setProcessor rejects. Both call sites used `void`, so this surfaced as

    Unhandled promise rejection: TypeError: Failed to construct
    'MediaStreamTrackProcessor': Input track cannot be ended

(element-call-rageshakes#17225). Skip ended tracks, and catch and log
processor attach/detach failures instead of leaking them.
2026-09-03 14:50:28 +01:00
Matthew Hodgson 8060d10fcc Don't report an aborted connect as a connection error
When the ConnectionManager stops a Connection while livekitRoom.connect()
is still pending (which happens on every join, because the local
membership emits twice in quick succession and the connection set is
recomputed), livekit-client rejects the pending connect with "Client
initiated disconnect". Connection.start() then treated that as a failure:
it pushed an UnknownCallError into the state of a connection that was
already stopped and rethrew, and since start() is fire-and-forget the
throw surfaced as

    Unhandled promise rejection: Error: Failed to connect to Livekit server

in every rageshake, right after a "livekitRoom.connect FAILED ... Client
initiated disconnect" line. Nothing was actually wrong: the replacement
connection connects fine a moment later.

Mark the connection as stopped before disconnecting, and have start()
return quietly when the rejection is the abort we asked for.
2026-09-03 14:48:23 +01:00
Valere 979b521563 Build Element Call as a component a host can import
Adds component/index.tsx as a fourth build target: <ElementCall client
roomId /> and an initializeElementCall to await once beforehand. It gives
Element Call everything it would otherwise take from the page it is on —
the parameters, the host bridge, media devices, translations, a container
to confine itself to — and hands it the host's client rather than finding
one of its own.

React, the Matrix SDK and LiveKit stay external, since the host has them
and a second copy of any would not merely be wasteful: React would hold
two sets of hooks and the client would run two sync loops. Every subpath
has to be listed by name, because the pattern and callback forms of
rollupOptions.external are silently ignored here — a lesson worth the
comment that records it.

Element Call's own navigation runs in a MemoryRouter, so being embedded
cannot disturb the host's URL. ClientContext and GroupCallView both
navigate, so some router has to be present.

The bundle is not yet a reasonable size: library mode base64-inlines
assets referenced through import.meta.url, so MediaPipe's vision runtime
lands in it whole. Left for its own change, since the fix — loading the
background blur transformer lazily — is worth doing for the standalone app
too.
2026-09-03 15:18:03 +02:00
Valere a166fbbd08 Let the component own its mute state
ElementCallView took muteStates as a prop, which would have meant an
embedder building one before it could show a call. It could not simply make
its own: RoomPage created one on mount whichever branch it went on to
render, and two would both report the user's mute state to the host,
talking over each other.

Move the construction into a useMuteStates hook, and give the lobby shown
while waiting to be let into a room its own component. Each lobby now holds
mute state only while it is on screen, so there is never a second one, and
the component can own the call's.

KnockLobbyView also takes the room summary and label handling that RoomPage
was assembling on its behalf, leaving the page with arriving at a call
rather than being in one.
2026-09-03 15:15:51 +02:00
Valere 856e055d30 Find Element Call's root by attribute, not by being the body
Six selectors named `body` directly — the gradient backdrop and the
platform font overrides in index.css, and the iOS adjustments in
AppBar.module.css and Modal.module.css — so they only applied when Element
Call owned the page. A host mounting it into a container would have got an
interface decorated correctly and styled incorrectly, with nothing to show
that anything was wrong.

Mark the root element with data-element-call-root and match on that
instead. Scoping this way keeps the selectors more specific than they were,
rather than less: widening them to a bare [data-platform=…] would have
dropped specificity from (0,1,1) to (0,1,0) and changed which rules win.

The platform attribute moves with them, from the initializer's write onto
document.body to a layout effect on the root, alongside the theme — so it
still lands before anything is painted.

No visual change while Element Call owns the page: the root is the body,
which now carries the attribute, so every rewritten selector matches the
element it always did.
2026-09-03 12:55:52 +02:00
Valere 1bfb018d90 Introduce ElementCallView
Being in a call and deciding to show one are different jobs, but RoomPage
did both: routing, authentication, resolving room aliases and knocking, and
then the call itself. Only the first set belongs to whatever is hosting
Element Call.

Add ElementCallView as the seam between them. It takes a client and a call
to join, and owns whether the user has joined — which is the call's own
business rather than its host's. RoomPage keeps everything about arriving
at a call and renders this for the call itself.

Nothing else moves yet. muteStates is still passed in, because the
standalone shell shares one with the lobby it shows while waiting to be let
into a room, and two instances would both report the user's mute state to
the host.
2026-09-03 12:10:04 +02:00
Valere ebb7fe07b7 add more test 2026-09-03 11:47:35 +02:00
Johannes Marbach d765267c43 Merge pull request #4229 from element-hq/matthew/fix-rageshake-flush
Fix periodic rageshake log flush never running
2026-09-03 07:29:51 +02:00
Matthew Hodgson 698a98a9c8 Test that logs are flushed to IndexedDB periodically 2026-09-02 18:55:18 +01:00
Valere 873dfdbfbd Drop the unused root element provider
RootElementProvider was exported but never used: nothing supplies a root
element, so every consumer falls back to the document body. Knip reports
it, which is what is failing CI.

M1 adds a provider back along with the component that mounts Element Call
into a container. Until then there is nothing to provide.
2026-09-02 19:46:44 +02:00
Matthew Hodgson 2db24e2868 Fix periodic rageshake log flush never running
The throttled flush callback returned this.flush instead of calling it
(regressed in #2607), so logs were only persisted to IndexedDB on
rageshake submission or beforeunload. When the host removes the widget
iframe at hangup, the whole call's logs were lost, so a rageshake filed
from a later call carries nothing from the affected one.
2026-09-02 18:46:12 +01:00
Valere e68d1d1b5f Say what the root element seam cannot do yet
RootElementContext's documentation promised that Element Call confines its
decoration to the given element, but several selectors still name body
directly and the initializer writes data-platform onto it. A non-body root
is decorated correctly and styled incorrectly, with no error to show for
it, so the documentation should say so until the stylesheets are scoped.
2026-09-02 19:28:14 +02:00
Valere b7efa02adc review: Follow a swapped client 2026-09-02 19:27:44 +02:00
Valere c807b8ef7d Always stop the widget transport when closing
close() sent io.element.close and then stopped the transport, so a
rejected send skipped the stop. Both call sites this replaced stopped it
unconditionally — ErrorView in a finally, GroupCallView outside its
try/catch — because a host that never acknowledges the request would
otherwise leave the messaging live and the close button doing nothing.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

src/state no longer refers to the widget API.

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

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

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

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

Move GroupCallView onto the host bridge

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No functional change.
2026-09-01 18:30:05 +02:00
renovate[bot] efdee7dcdb Update ghcr.io/element-hq/element-web:develop Docker digest to c107ec3 (#4221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-09-01 02:53:52 +00:00
Johannes Marbach d7d3dcde58 Merge pull request #4219 from element-hq/renovate/matrix-widget-api
Update dependency matrix-widget-api to v1.19.0
2026-08-31 07:34:07 +02:00
renovate[bot] 9330b907fc Update ghcr.io/element-hq/element-web:develop Docker digest to 7c5eb96 (#4218)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-31 00:49:32 +00:00
renovate[bot] 256ff7a0b6 Update dependency matrix-widget-api to v1.19.0 2026-08-31 00:40:41 +00:00
renovate[bot] 6ddcc15206 Update ghcr.io/element-hq/element-web:develop Docker digest to e8727bb (#4216)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-30 01:09:28 +00:00
renovate[bot] a56e934841 Update ghcr.io/element-hq/element-web:develop Docker digest to e29b408 (#4215)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-29 02:25:08 +00:00
Timo efde51b8c1 Merge pull request #4172 from element-hq/toger5/add-key-rotation-participant-limit
Add key rotation participant limit to devtools and config.json
2026-08-28 16:38:20 +02:00
Timo K. 2a7c6c3c06 Oh dear test coverage... 2026-08-28 16:22:08 +02:00
Timo K. 5bab17b668 Update InCallView.tsx 2026-08-28 15:52:52 +02:00
Timo K. d7d28a1b55 formatting 2026-08-28 15:50:48 +02:00
Timo K. 790bd1e2bf Update LocalMember.test.ts 2026-08-28 15:44:46 +02:00
Timo K. 17231e545c Update DeveloperSettingsTab.test.tsx 2026-08-28 15:38:57 +02:00
Timo K. 17d9d19c91 ObservableScope -> testScope 2026-08-28 15:32:59 +02:00
Timo K. 3d37abf490 merge two tests 2026-08-28 15:31:23 +02:00
Timo K. 92f504d886 remove oldest membership mocking 2026-08-28 15:29:16 +02:00
Timo K. b0d098e065 Merge branch 'main' into toger5/add-key-rotation-participant-limit 2026-08-28 15:27:43 +02:00
Timo K. a526a8ae19 Update config.sample.json 2026-08-28 15:26:00 +02:00
Timo K. 1146d3820a review. Dont pass full vm to developer settings 2026-08-28 15:20:49 +02:00
renovate[bot] a4dda428f5 Update ghcr.io/element-hq/element-web:develop Docker digest to 133160c (#4213)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-28 03:01:16 +00:00
renovate[bot] 5982586f24 Update ghcr.io/element-hq/element-web:develop Docker digest to f163e7a (#4211)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-27 01:06:37 +00:00
Robin e6b4aae714 Merge pull request #4208 from element-hq/remove-legacy-mode
Remove MatrixRTC legacy mode
2026-08-26 20:27:46 +02:00
Robin ede29bdf1b Remove MatrixRTC legacy mode
This is the mode in which we sent membership events with the 'oldest membership' transport selection algorithm, which stopped being the default back in version 0.21.0. Users will no longer be able to select this mode in developer settings, and admins will no longer be able to select legacy mode through the config either. The app will still continue to support *receiving* membership events with the 'oldest membership' transport selection algorithm from others, however.
2026-08-26 19:51:12 +02:00
Robin b5b2bd2193 Merge pull request #4207 from element-hq/legacy-member-events
Remove feature_use_device_session_member_events
2026-08-26 19:49:20 +02:00
Robin 21ca382306 Fix tests 2026-08-26 19:14:19 +02:00
Robin 25e379546e Retry CI 2026-08-26 18:32:14 +02:00
Robin 0443ef55d1 Remove feature_use_device_session_member_events
Support for events with an array of memberships from different devices was removed over a year ago in matrix-js-sdk ffd3c9575e9def576739baf6b1dc329b0db55c0c.
2026-08-26 17:05:38 +02:00
renovate[bot] 465c413691 Update ghcr.io/element-hq/element-web:develop Docker digest to 0332028 (#4204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-26 09:54:28 +02:00
Timo K. 06b9105cee Update DeveloperSettingsTab.test.tsx 2026-08-25 23:32:22 +02:00
Timo K. 2b5979162a add more tests 2026-08-25 23:30:22 +02:00
Timo K. 5399f14012 add tests 2026-08-25 22:43:15 +02:00
Timo K. f2ffb06f6c use develop branch of matrix-js-sdk 2026-08-25 22:03:59 +02:00
Johannes Marbach 6cf0e1df51 Merge pull request #4196 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v10.2.4
2026-08-24 11:00:01 +02:00
renovate[bot] bfa20ef747 Update ghcr.io/element-hq/element-web:develop Docker digest to ccee84c (#4195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-24 09:35:45 +02:00
Johannes Marbach 5955d22c1b Merge pull request #4197 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.22.0
2026-08-24 08:51:19 +02:00
renovate[bot] ce4426858b Update dependency livekit-client to v2.22.0 2026-08-24 00:37:56 +00:00
renovate[bot] 4ec75ecda4 Update dependency @vector-im/compound-design-tokens to v10.2.4 2026-08-24 00:37:19 +00:00
Timo b51a33c4e1 Merge pull request #4194 from element-hq/toger5/rtc-mode-documentation
Add docs about currently used matrixRTC mode
2026-08-21 17:44:10 +02:00
Timo K. c3837464a3 Add docs about currently used matrixRTC mode 2026-08-21 12:04:24 +02:00
Timo e7372d4c03 Merge pull request #4190 from element-hq/toger5/update-branche-ci-main
`livekit` -> `main` Update branches used in ci and tooling to main
2026-08-21 11:55:32 +02:00
renovate[bot] 69eddd7846 Update ghcr.io/element-hq/element-web:develop Docker digest to ee5b90c (#4193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 07:10:30 +02:00
Timo K. de3e4c6a78 update branches used in ci and tooling to main 2026-08-20 15:44:13 +02:00
Robin 3abc5a0435 Merge pull request #4185 from element-hq/pip-no-modals
Hide reactions interface in PiP mode
2026-08-20 15:16:22 +02:00
Johannes Marbach 8e20d48b1d Merge pull request #4187 from element-hq/johannes/label-sync-pat
Switch label sync workflow to LABEL_SYNC_GITHUB_TOKEN
2026-08-20 10:05:12 +02:00
Johannes Marbach ff57c217aa Switch label sync workflow to LABEL_SYNC_GITHUB_TOKEN 2026-08-20 09:21:42 +02:00
renovate[bot] b60e7452b7 Update ghcr.io/element-hq/element-web:develop Docker digest to a416986 (#4186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-20 07:14:58 +02:00
Robin 1c03ad335c Hide reactions interface in PiP mode 2026-08-19 17:44:45 +02:00
Robin 87873b726f Merge pull request #4183 from element-hq/msc4519-docs
Update docs to reflect transports endpoint moving to MSC4519
2026-08-19 15:58:28 +02:00
Robin 3448a3a2ef Update docs to reflect transports endpoint moving to MSC4519
This is linked from the 0.24.0 release notes, so even more important for it to be up to date.
2026-08-19 11:48:48 +02:00
Robin c625fb8845 Merge pull request #4180 from element-hq/background-pngs
Performance: Use PNGs for the background gradients
2026-08-19 10:40:07 +02:00
Robin cab9698fc7 Undo breakpoint change made during testing 2026-08-19 10:24:26 +02:00
Robin 7410bd8a82 Merge pull request #4181 from element-hq/lobby-video-border
Fix lobby video preview losing its rounded corners in Firefox
2026-08-19 08:54:57 +02:00
renovate[bot] f17670b731 Update ghcr.io/element-hq/element-web:develop Docker digest to 9ac7cde (#4182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 08:09:58 +02:00
Robin 33a0653ea5 Fix lobby video preview losing its rounded corners in Firefox
Same fix as e1bc4a096b, but for the video preview in the lobby.
2026-08-18 17:49:14 +02:00
Robin b1c29e9a41 Performance: Use PNGs for the background gradients
It turns out that for a blurry gradient background, low-res raster images look very convincing when scaled up and interpolated. That means that we can rasterize our gradient SVGs for improved paint performance when resizing the window, while still keeping them quite lightweight (just a couple of kilobytes each).
2026-08-18 17:30:12 +02:00
Robin ad92892063 Merge pull request #4177 from element-hq/ignore-layout-updates
Performance: Ignore redundant layout updates
2026-08-18 15:39:49 +02:00
Robin 1a827e0930 Merge pull request #4178 from element-hq/tiles-debug-performance
Performance: Isolate tile store debug info in its own component
2026-08-18 13:05:02 +02:00
Robin a1b37bb3c9 Performance: Ignore redundant layout updates
When someone starts or stops speaking, the layout will often be recomputed only to find out that there is ultimately no layout change. We can ignore these redundant updates to avoid re-rendering the InCallView and Grid components, which are relatively slow.

For that specific, common case, this reduces JS CPU usage by as much as 70% in my testing.
2026-08-18 08:42:59 +02:00
renovate[bot] 061d25a54c Update ghcr.io/element-hq/element-web:develop Docker digest to 073e6a4 (#4176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-18 07:48:41 +02:00
Robin b554fa63d1 Performance: Isolate tile store debug info in its own component
It's very uncommon to have this debug option enabled, and yet it currently causes the footer to re-render on every layout update, which is a small but avoidable cost.
2026-08-17 16:45:26 +02:00
Robin b5bcb9aeed Merge pull request #4160 from alexmaras/room-joining/assume-public
Room joining: if join_rule is absent, assume public
2026-08-17 14:40:07 +02:00
Timo 3d3e6dcdcc Merge pull request #4171 from element-hq/toger5/fix-unclose-able-error-screen
Fix: error screen cannot be closed in embedded mode.
2026-08-17 14:16:37 +02:00
Timo K. 9999a755dd Update pnpm-lock.yaml 2026-08-17 12:33:31 +02:00
Timo K. 43ce68e80a Fix: error screen cannot be closed in embedded mode. 2026-08-17 12:08:30 +02:00
Robin 6d5f698357 Merge pull request #4151 from element-hq/more-more-performance
Performance: Fit video to frame without polling RTP stats
2026-08-17 11:36:37 +02:00
Robin 64a5b2ee5c Clarify how an invalid width and height manifest 2026-08-17 11:19:43 +02:00
Timo K. 9857ed53e3 Add key roatation limit to config.json 2026-08-17 11:17:22 +02:00
Timo K. 0aa9d94826 add key rotation debug information 2026-08-17 11:17:22 +02:00
Timo K. f2622f0032 Add vm to developer settings tab 2026-08-17 11:17:22 +02:00
Robin c0d5f2d470 Merge branch 'livekit' into more-more-performance 2026-08-17 11:12:17 +02:00
Robin 4a5141406e Merge pull request #4152 from element-hq/more-more-more-performance
Performance: Disable animations when there are a large number of tiles
2026-08-17 11:11:39 +02:00
Robin b036d1c0c0 Merge pull request #4169 from element-hq/renovate/major-compound
Update dependency @vector-im/compound-web to v10
2026-08-17 09:25:18 +02:00
Robin 0cfe6cb03d Merge pull request #4168 from element-hq/renovate/livekit-components
Update LiveKit components
2026-08-17 09:21:33 +02:00
renovate[bot] 8be8cf08bf Update ghcr.io/element-hq/element-web:develop Docker digest to abd6473 (#4165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-17 09:19:40 +02:00
renovate[bot] 9a772131b6 Update dependency @vector-im/compound-web to v10 2026-08-17 00:38:58 +00:00
renovate[bot] 1cdd5d8db0 Update LiveKit components 2026-08-17 00:38:39 +00:00
renovate[bot] fc9e13c922 Update ghcr.io/element-hq/element-web:develop Docker digest to 6176839 (#4163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-14 12:49:32 +02:00
Robin 580897730a Merge pull request #4150 from element-hq/more-performance
Performance: Avoid re-rendering context menus so often
2026-08-12 17:30:13 +02:00
Robin 6f7dac31d6 Merge pull request #4153 from element-hq/valere/rtc/remove_well_known_fallback
Remove .well-known transport discovery
2026-08-11 18:24:03 +02:00
Robin 78e4881c37 Enable MatrixRTC transports endpoint in dev and testing configs 2026-08-11 18:03:28 +02:00
Alex Maras 1bb342e9e1 Room joining: if join_rule is absent, assume public 2026-08-11 21:24:38 +08:00
Robin 7ca68fb5bd Merge branch 'livekit' into valere/rtc/remove_well_known_fallback 2026-08-11 12:29:36 +02:00
Timo 72e7e0f406 Merge pull request #3736 from emmick4/livekit
feat: advanced media quality settings UI and config-driven defaults
2026-08-11 12:27:12 +02:00
Robin 94a1d87813 Merge pull request #4158 from element-hq/spotlight-performance
Performance: Avoid re-rendering entire spotlight layout so often
2026-08-11 12:24:42 +02:00
Robin fee54a09c3 Merge branch 'livekit' into valere/rtc/remove_well_known_fallback 2026-08-11 11:45:26 +02:00
Robin 72a327f12f Merge pull request #4135 from element-hq/valere/widget_rtc_transport_action
Use widget API action to get transports in widget mode
2026-08-11 11:41:35 +02:00
Robin 7f8d8027dc Request capability to get RTC transports over widget API 2026-08-11 11:23:50 +02:00
Ryan Emmick cbc12b0365 Merge remote-tracking branch 'upstream/livekit' into livekit 2026-08-11 02:50:16 -05:00
Robin 414ae266ab Merge branch 'livekit' into valere/widget_rtc_transport_action 2026-08-11 09:48:22 +02:00
renovate[bot] ecca5e1e13 Update ghcr.io/element-hq/element-web:develop Docker digest to f25f10f (#4159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-11 09:25:55 +02:00
Ryan Emmick 1d12e10628 Merge remote-tracking branch 'upstream/livekit' into livekit 2026-08-10 12:12:18 -05:00
Ryan Emmick 89cabf9fa2 test: cover rageshake media quality metadata and preset edge cases 2026-08-10 11:41:26 -05:00
Robin 7ca8059b0f Remove unused access token mocks 2026-08-10 17:10:31 +02:00
Timo K. 29ea90f800 add rageshake metadata and log 2026-08-10 15:48:53 +02:00
Robin 426ea30fe1 Remove all references to .well-known transport advertisement 2026-08-10 15:10:49 +02:00
Robin 7d6a0da6a9 Remove outdated tests 2026-08-10 14:33:14 +02:00
Robin ea31f073a7 Merge branch 'valere/widget_rtc_transport_action' into valere/rtc/remove_well_known_fallback 2026-08-10 14:32:44 +02:00
Timo K. b0d08cb0b5 update order in developer settings tab 2026-08-10 14:22:04 +02:00
Timo K. 88e5a5badb More resolution tests. 2026-08-10 14:21:49 +02:00
Robin 296c735500 Merge branch 'livekit' into valere/widget_rtc_transport_action 2026-08-10 13:43:42 +02:00
Robin 341c508a42 Remove outdated tests 2026-08-10 13:22:37 +02:00
Robin 78e585c5f4 Update matrix-js-sdk to support RTC transport discovery over widget API
Pulls in commit 0d8558de2c682a8cd719d385bfa0b5c01f2fb69e from matrix-js-sdk
2026-08-10 13:14:45 +02:00
Robin 81da4c9093 Remove need to approve builds of matrix-js-sdk after every update 2026-08-10 13:13:58 +02:00
Robin 064a88b9c7 Update pnpm to 11.21.0 2026-08-10 13:11:14 +02:00
Robin 14fd8c179b Fix formatting 2026-08-10 12:55:29 +02:00
renovate[bot] 4a146ebada Update ghcr.io/element-hq/element-web:develop Docker digest to 5c9a7f0 (#4157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-10 11:41:51 +02:00
Robin a6a02778a1 Performance: Avoid re-rendering entire spotlight layout so often
This removes a lot of unnecessary jank in spotlight layout when swapping individual speakers in and out of the spotlight tile.
2026-08-10 11:16:35 +02:00
Ryan Emmick 662259154a Merge remote-tracking branch 'upstream/livekit' into livekit 2026-08-07 03:16:12 -05:00
renovate[bot] 978ffee183 Update ghcr.io/element-hq/element-web:develop Docker digest to a23cced (#4155)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-07 09:28:35 +02:00
Ryan Emmick 3ace4ab5a3 Merge remote-tracking branch 'upstream/livekit' into livekit 2026-08-06 02:53:13 -05:00
renovate[bot] 2d64cf4b30 Update ghcr.io/element-hq/element-web:develop Docker digest to 6fb12e1 (#4154)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-06 09:38:48 +02:00
Ryan Emmick 87aee1c02c test: update developer settings snapshot after merge 2026-08-05 12:05:41 -05:00
Ryan Emmick f7cf674152 Merge remote-tracking branch 'upstream/livekit' into livekit 2026-08-05 11:55:38 -05:00
Valere 9d5ca297e3 Remove well-known discovery 2026-08-05 16:12:41 +02:00
Robin c90d1d8dda Expand GridTile test coverage 2026-08-05 12:08:18 +02:00
Robin dd59519a85 Performance: Disable animations when there are a large number of tiles
I don't believe this affects the cost of running the useSprings hook, but it at least skips the animation frames that would otherwise follow, so it's worth a try.
2026-08-05 11:49:55 +02:00
Robin 33fc8997f8 Performance: Use a shared timer for polling RTP stats
This is now only relevant in case the user has enabled the developer option to show advanced media statistics, but still an easy performance fix.
2026-08-05 11:18:25 +02:00
Robin 8aead74ab9 Performance: Lower the RTP stats refresh interval
Because we no longer use it for computing the fit to frame setting; it's only for developer tools.
2026-08-05 11:18:22 +02:00
Robin 1e128c4da0 Performance: Fit video to frame without polling RTP stats
It turns out that the timers which repeatedly poll the RTP stats of each video track all run independently of each other and can add up to a small but constant sink of CPU. Meanwhile we can replace these timers with HTMLVideoElement 'resize' event listeners, which is way more efficient and reacts instantly to orientation changes.
2026-08-05 11:18:22 +02:00
Robin eb65a28a75 Merge pull request #4146 from element-hq/performance
Performance: Enable React Compiler
2026-08-05 11:10:40 +02:00
Robin 9d400323d0 Clarify why certain tests disable the React Compiler 2026-08-05 10:54:49 +02:00
renovate[bot] 6d7d3093b3 Update ghcr.io/element-hq/element-web:develop Docker digest to b4e2d2d (#4149)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-05 10:16:05 +02:00
Robin 2b07e9e822 Merge pull request #4148 from element-hq/renovate/i18next-26.x
Update dependency i18next to v26
2026-08-04 22:49:21 +02:00
renovate[bot] 7ae1fc6cd8 Update dependency i18next to v26 2026-08-04 17:21:53 +00:00
Robin e1df71817a Performance: Avoid re-rendering context menus so often
A change in a tile's speaking indicator could cause its entire tree of context menu components to re-render, which is expensive. Isolating the behavior subscriptions in their own component avoids this.
2026-08-04 17:57:25 +02:00
Robin 608695fc95 Performance: Enable React Compiler
With the React Compiler, our component code is transformed at build time to automatically apply various forms of memoization. This changes the runtime semantics of our code a little bit and therefore could surface new bugs in the next release cycle in case any components fail to follow the rules of React. See https://react.dev/learn/react-compiler for more information.

This results in a small bundle size increase and modest performance gains, but I think it's worth it given the potential for larger performance gains once we tune the component structure a bit more.
2026-08-04 17:57:23 +02:00
Robin a08a577e18 Merge pull request #4138 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-08-04 17:19:28 +02:00
Robin 55b2d2b130 Remove possibility for CI Playwright version to get out of sync 2026-08-04 17:04:07 +02:00
renovate[bot]andRobin 5b84b2c04d Update ghcr.io/element-hq/element-web:develop Docker digest to 3daefff (#4116)
* Update ghcr.io/element-hq/element-web:develop Docker digest to 0183c0a

* Update end-to-end tests to work with auto-collapsing room list

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
2026-08-04 12:35:18 +00:00
Robin 3af0744324 Merge branch 'livekit' into renovate/all-minor-patch 2026-08-04 14:20:13 +02:00
Robin 07567ed31d Merge pull request #4118 from element-hq/renovate/livekit-components
Update LiveKit components
2026-08-04 14:05:47 +02:00
Robin d6e097476e Address linter finding 2026-08-04 13:51:56 +02:00
Robin 540eb306f4 Fix unit tests 2026-08-04 13:48:52 +02:00
Robin f62428ba0d Update code formatting 2026-08-04 13:43:38 +02:00
renovate[bot] d579169d54 Update all non-major dependencies 2026-08-04 11:39:13 +00:00
Robin 8daaa649eb Merge pull request #4147 from element-hq/renovate/npm-postcss-vulnerability
Update dependency postcss to v8.5.23 [SECURITY]
2026-08-04 13:36:56 +02:00
Robin 2d5a4eefec Merge pull request #4139 from element-hq/renovate/posthog
Update dependency posthog-js to v1.408.2
2026-08-04 13:26:18 +02:00
renovate[bot] d550430c8a Update dependency postcss to v8.5.23 [SECURITY] 2026-08-04 11:11:52 +00:00
Robin 78724acfcf Merge pull request #4140 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.9.0
2026-08-04 13:10:48 +02:00
Robin 20c7112812 Merge pull request #4141 from element-hq/renovate/matrix-widget-api
Update dependency matrix-widget-api to v1.18.0
2026-08-04 13:09:58 +02:00
renovate[bot] a7c996f3ea Update dependency posthog-js to v1.408.2 2026-08-04 11:07:26 +00:00
Valere Fedronic 4fbcbb4849 Merge pull request #4136 from element-hq/valere/fix_4132
fix: FF mirrored self video lose rounding
2026-08-03 13:53:04 +02:00
Robin 74102ac6c2 Merge pull request #3075 from hughns/defaut-server-config
Remove default server config as it is not likely to be correct
2026-08-03 10:49:49 +02:00
Robin 11a6fa9b45 Fix tests 2026-08-03 10:32:52 +02:00
Robin 507a6f29a5 Merge branch 'livekit' into defaut-server-config 2026-08-03 10:29:41 +02:00
renovate[bot] e725bc5de3 Update dependency matrix-widget-api to v1.18.0 2026-08-03 03:03:18 +00:00
renovate[bot] c15d4c50a1 Update dependency @vector-im/compound-web to v9.9.0 2026-08-03 03:02:53 +00:00
Valere e1bc4a096b fix: FF mirrored self video lose rounding 2026-07-31 12:20:32 +02:00
renovate[bot] b524de9298 Update LiveKit components 2026-07-30 20:51:22 +00:00
Robin bfb5fb922f Merge pull request #4134 from element-hq/renovate/npm-postcss-vulnerability
Update dependency postcss to v8.5.18 [SECURITY]
2026-07-30 11:01:41 +02:00
renovate[bot] 3f4d55343a Update dependency postcss to v8.5.18 [SECURITY] 2026-07-30 08:45:15 +00:00
Robin 73d8d2c2c3 Merge pull request #4120 from element-hq/johannes/i18n-download-fixes
Apply correct GH label and fix ordering issue in i18n download
2026-07-30 10:45:09 +02:00
Robin fcfe73c50d Merge pull request #4117 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.21.0
2026-07-30 10:43:24 +02:00
Valere ffb6b0a5f6 Use rtc transport action with widgets 2026-07-30 10:43:09 +02:00
renovate[bot] 5564015a8b Update dependency livekit-client to v2.21.0 2026-07-28 18:56:05 +00:00
Robin 62eeb10d38 Merge pull request #4130 from element-hq/landscape-default-grid
Only show layout switch when it has an effect on the layout
2026-07-28 15:28:06 +02:00
Robin 2de7fdde10 Fix layout switches not showing their state on the combined docs page
On the combined Storybook docs page for the call footer, the layout switches would not show the correct state until you hovered over them because they all shared the same input name and thus were interpreted as belonging to the same radio group.
2026-07-28 15:06:15 +02:00
Robin 5a96d1769e Only show layout switch when it has an effect on the layout 2026-07-28 15:06:15 +02:00
Robin a443a55bec Factor out a layout switch view + view model
So it can easily be shown and hidden wholesale.
2026-07-28 15:06:15 +02:00
Robin 9b2d9fdd1f Merge pull request #4129 from element-hq/landscape-default-grid
Prefer grid mode in landscape on mobile
2026-07-28 14:29:30 +02:00
Robin 05ad9b2475 Merge pull request #4128 from element-hq/landscape-default-grid
Use a proper one-on-one layout for landscape mobile calls
2026-07-28 14:08:48 +02:00
Robin 7962d02be5 Prefer grid mode in landscape on mobile 2026-07-28 11:56:01 +02:00
Robin 5c35288162 Turn the local video tile landscape in landscape mobile one-on-one calls 2026-07-28 11:53:26 +02:00
Robin aa6732da19 Use an actual one-on-one layout in landscape on mobile 2026-07-28 11:53:26 +02:00
Valere Fedronic 65ac11e952 Merge pull request #4125 from element-hq/valere/bug_poster_safari
fix: Safari preview tile no video is 16px wide only
2026-07-28 10:55:01 +02:00
Robin 0b21cff020 Rename one-on-one layouts to be orientation-agnostic 2026-07-27 18:28:52 +02:00
Robin 062e0a00fb Remove unused variables from CallViewModel tests 2026-07-27 18:13:51 +02:00
Valere 53ae78c609 break ratio when tile bigger than screen 2026-07-27 17:25:56 +02:00
Valere b046c4b569 fix: Safari preview tile no video is 16px wide only 2026-07-27 15:43:35 +02:00
Johannes Marbach 46cb89b232 Apply correct GH label and fix ordering issue in i18n download 2026-07-20 17:38:14 +02:00
ElementRobot e20abb19fb Merge pull request #4119 from element-hq/actions/localazy-download
Localazy Download
2026-07-20 16:23:58 +01:00
Johannes Marbach bf76f47357 delint 2026-07-20 17:03:55 +02:00
Johennes 9c2d3d067d Translations updates 2026-07-20 14:58:37 +00:00
Robin ae7ede3201 Merge pull request #4113 from element-hq/vulnerable-dependencies
Resolve most existing Dependbot warnings
2026-07-16 09:28:13 +02:00
Robin e5ff431b7a Merge pull request #4112 from element-hq/disable-poster
Hide the browser's default placeholder image for videos
2026-07-16 09:21:27 +02:00
Robin 66a676f2f5 Resolve most existing Dependbot warnings 2026-07-15 18:05:06 +02:00
Robin d33dedd038 Hide the browser's default placeholder image for videos
Source: https://stackoverflow.com/a/79058682
2026-07-15 17:26:02 +02:00
renovate[bot]andRobin 1f2837f86a Update ghcr.io/element-hq/element-web:develop Docker digest to 07ccf47 (#4111)
* Update ghcr.io/element-hq/element-web:develop Docker digest to d184182

* Target the toast 'Join' button specifically in end-to-end tests

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Robin <robin@robin.town>
2026-07-15 14:34:34 +00:00
Robin 927cf68110 Merge pull request #4109 from element-hq/service-interruptions-vm
Create service interruptions view model
2026-07-14 12:21:18 +02:00
Robin cd72ad0008 Create service interruptions view model 2026-07-14 12:02:36 +02:00
Robin 2d2e85e340 Merge pull request #4107 from element-hq/spotlight-icon
Update spotlight icon
2026-07-14 09:34:50 +02:00
Johannes Marbach 4da1b9a02a Merge pull request #4082 from element-hq/johannes/docker-compose-renovate
Make renovate track docker-compose files and pin synapse / element-web for playwright tests
2026-07-14 07:55:17 +02:00
Robin 73758f7f77 Merge pull request #4108 from element-hq/renovate/compound
Update Compound
2026-07-13 18:37:15 +02:00
Robin c433c36687 Update snapshots 2026-07-13 18:20:56 +02:00
Robin 3e9d7b7cf7 Exclude packages developed in-house from release age requirements 2026-07-13 17:00:02 +02:00
renovate[bot] 2a56d2afb8 Update Compound 2026-07-13 14:35:37 +00:00
Robin bff41ad66a Update spotlight icon 2026-07-13 12:22:44 +02:00
Robin 1d088911e1 Merge pull request #4105 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.7.1
2026-07-13 11:10:21 +02:00
Timo f23eb96555 Merge pull request #4025 from mvanhorn/fix/3915-settings-tabbar-announce-position
fix: announce tab count and position in settings tab bar
2026-07-13 10:16:52 +02:00
renovate[bot] 0d4e7defb6 Update dependency @vector-im/compound-web to v9.7.1 2026-07-13 00:42:33 +00:00
Timo K. 023aec22ca Update docker-compose-playwright.yml 2026-07-10 17:45:53 +02:00
Timo K. bf4cee9898 update to latest EW
(this fixes the playwright tests)
2026-07-10 17:39:36 +02:00
Timo K. 147ff131f4 Merge branch 'livekit' into johannes/docker-compose-renovate 2026-07-10 12:59:06 +02:00
Robin 3e5edd03a6 Merge pull request #4100 from element-hq/remove-stopgap-styles
Stop using colors from previous design system
2026-07-09 16:37:02 +02:00
Robin 0afefa4889 Merge branch 'livekit' into remove-stopgap-styles 2026-07-09 16:21:06 +02:00
Timo K. 49a762832b Merge branch 'livekit' into johannes/docker-compose-renovate 2026-07-09 14:03:18 +02:00
Timo K. 3cf5cab1d2 Add additional renovate packageGroup for element-web 2026-07-09 13:20:57 +02:00
Robin 5968554e4c Merge pull request #4067 from element-hq/mobile-gradient
Improve the look of voice calls and group calls on mobile
2026-07-09 13:17:12 +02:00
Robin 8d826b2f31 Avoid showing gradients in especially small windows 2026-07-09 12:59:29 +02:00
Robin 32e30dd4f5 Merge branch 'livekit' into mobile-gradient 2026-07-09 12:56:28 +02:00
Robin bb06905aa2 Merge pull request #4099 from element-hq/stable-class-names
Use vitest's default for scoped class names
2026-07-09 12:54:19 +02:00
Robin 2684fa1d62 Remove unused class for end call button 2026-07-09 12:46:45 +02:00
Robin de75a69cad Remove stopgap color variables from previous design system
They have been largely unused for a long time. This changes the colors of checkboxes and the 'end call' button to align with current designs as well.
2026-07-09 12:28:39 +02:00
Timo f2c42c314c Merge pull request #4088 from element-hq/toger5/logger-getChild-fix
fix getChild before rageshake setup
2026-07-08 18:19:04 +02:00
Robin 4b50a191ba Fix end-to-end tests 2026-07-08 17:13:15 +02:00
Robin 11fadebb87 Fix test failures due to scoped class names 2026-07-08 17:06:25 +02:00
Robin 943ce5b853 Update test snapshots to use scoped class names 2026-07-08 17:06:09 +02:00
Timo K. 137093350b Merge branch 'livekit' into toger5/logger-getChild-fix 2026-07-08 17:04:42 +02:00
Robin 000ee66cfe Use vitest's default for scoped class names
It reduces confusion when reading snapshots if class names are still scoped to their respective CSS modules. It also discourages the use of class names in tests, which is a good thing. (https://testing-library.com/docs/guiding-principles)

Thanks to Johannes for the suggestion.
2026-07-08 17:03:51 +02:00
Johannes Marbach 58a8b7316e Merge pull request #4077 from element-hq/renovate/github-actions
Update GitHub Actions
2026-07-08 16:20:07 +02:00
renovate[bot] 1e6d3d3357 Update GitHub Actions 2026-07-08 14:02:37 +00:00
Robin 9f681b87a0 Merge branch 'livekit' into mobile-gradient 2026-07-08 15:47:59 +02:00
Timo dc21233767 Merge pull request #4097 from element-hq/toger5/fix-playwright-related-to-voice-in-pip
Consider voice calls starting in pip for playwright tests
2026-07-08 15:42:50 +02:00
Robin 27315a7090 Rename bgStyle to 'background' 2026-07-08 14:01:38 +02:00
Robin bebe79d109 Create type alias for background style 2026-07-08 13:56:10 +02:00
Robin 4ca0270115 Fix broken gradient SVG 2026-07-08 13:52:24 +02:00
Timo K. 374f56651c Consider voice calls starting in pip for playwright tests 2026-07-08 13:19:23 +02:00
Timo K. eefa6f843d fmt 2026-07-07 19:52:50 +02:00
Timo K. 2b84c587f2 lint 2026-07-07 19:45:22 +02:00
Timo K. 706cddafb0 Add lint rule to make sure future child loggers are setup correctly 2026-07-07 19:34:03 +02:00
Timo K. a6921e2bfb all the other logs 2026-07-07 19:12:11 +02:00
Timo K. 3cc396f42c fix getChild before rageshake setup 2026-07-07 19:05:10 +02:00
Timo 59999b9b37 Merge pull request #4059 from element-hq/toger5/remove-blur-on-mobile
Remove blur on mobile
2026-07-07 15:30:36 +08:00
Robin e68321e16c Merge branch 'livekit' into mobile-gradient 2026-07-06 12:17:04 +02:00
Johannes Marbach ad76969ce2 Merge pull request #4084 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.7.0
2026-07-06 09:03:10 +02:00
Johannes Marbach c170aad364 Merge pull request #4085 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.20.0
2026-07-06 09:01:51 +02:00
renovate[bot] 9e8bebc3bf Update dependency livekit-client to v2.20.0 2026-07-06 02:54:08 +00:00
renovate[bot] d5cdbfd4d1 Update dependency @vector-im/compound-web to v9.7.0 2026-07-06 02:53:46 +00:00
Johannes Marbach 1811b36205 Make renovate track docker-compose files and pin synapse / element-web for playwright tests 2026-07-03 08:48:28 +02:00
Robin 7840f4bccb Merge branch 'livekit' into mobile-gradient 2026-07-02 13:00:38 +02:00
Johannes Marbach 9199dcb583 Merge pull request #4076 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-07-02 11:37:07 +02:00
Johannes Marbach cbc4b77a4f Dismiss Element Web release announcements 2026-07-02 11:19:16 +02:00
Johannes Marbach ffb881436a Eliminate @livekit/protocol 2026-07-02 08:47:07 +02:00
Johannes Marbach 9cf87efc6b Remove global-jsdom 2026-07-02 08:35:59 +02:00
Johannes Marbach b71454d63a Bump mcr.microsoft.com/playwright 2026-07-02 08:35:40 +02:00
renovate[bot] b0feb33ec4 Update all non-major dependencies 2026-07-01 00:31:28 +00:00
Robin 900d0abd27 Let page background shine through the footer
By visually separating it from other page content with an additional gradient background only when necessary.
2026-06-30 16:29:20 +02:00
Robin a3d3a1e951 Add "sound waves" to transparent spotlight tiles
To indicate when the user is speaking.
2026-06-30 16:29:20 +02:00
Robin 8fa3f33f37 Let page background shine through spotlight tiles
By giving spotlight tiles a transparent background in certain layouts.
2026-06-30 16:29:20 +02:00
Robin 360a4ff026 Add mobile gradient background 2026-06-30 16:29:20 +02:00
Robin 8c6a16f3d7 Add URL param to control background style 2026-06-30 16:29:20 +02:00
Robin 9d21581259 Dismiss sections release announcement in end-to-end tests 2026-06-30 16:29:20 +02:00
Robin 3837576a5f Merge pull request #4073 from element-hq/refine-scope-leak-lint
Fix some false positives flagged by ObservableScope leak lint rule
2026-06-29 14:57:50 +02:00
Johannes Marbach eb5a097999 Merge pull request #4072 from element-hq/johannes/jest
Switch to jest-style assertions to please oxlint
2026-06-29 14:36:18 +02:00
Robin db2d20ac56 Fix some false positives flagged by ObservableScope leak lint rule
The rule should only care about enclosing function/class scopes. For example if an ObservableScope is received as a parameter to a function and then simply used inside an 'if' block (technically a different scope), that's not a problem.
2026-06-29 14:33:23 +02:00
Johannes Marbach f276c51772 Switch to jest-style assertions to please oxlint
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-29 14:32:49 +02:00
Johannes Marbach ac2214ed77 Merge commit from fork
Restrain logging of URL properties
2026-06-29 14:21:05 +02:00
Timo 8fe7bb5329 Merge pull request #4066 from element-hq/toger5/matrixRTCMode-compatibility
Default to compatibilty mode (multi sfu) - non breaking, as multi-sfu is compatible with all versions younger 6 month
2026-06-29 19:41:23 +08:00
Timo K 8028a6aa24 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:11:26 +02:00
Timo K 5b4e8865e5 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:04:22 +02:00
Timo K 356d526116 Update sfu-reconnect-bug.spec.ts 2026-06-29 13:02:26 +02:00
Johannes Marbach cca69b8108 Merge pull request #4069 from element-hq/renovate/compound
Update dependency @vector-im/compound-web to v9.5.0
2026-06-29 08:00:57 +02:00
renovate[bot] 75235a7853 Update dependency @vector-im/compound-web to v9.5.0 2026-06-29 00:34:12 +00:00
Timo K 0421783ebe Update config.sample.json 2026-06-26 13:30:19 +02:00
Robin a1be4ff0f9 Merge pull request #4068 from element-hq/landscape-voice-call
Hide local tile from mobile voice calls in landscape orientation
2026-06-26 11:22:25 +02:00
Robin 8ab7f89e36 Hide local tile from mobile voice calls in landscape orientation
To match the designs. Just like in portrait, it wouldn't contribute anything meaningful.
2026-06-25 20:29:24 +02:00
Valere Fedronic ddcb0f9981 Merge pull request #4064 from element-hq/ios/default_voice_call_to_earpiece
fix(voice): Default to earpiece for voice only call
2026-06-25 16:32:59 +02:00
Timo K 6ca5ddda73 fix snapshot 2026-06-25 16:05:58 +02:00
Valere 41d0933d4d playwright: New toast 2026-06-25 15:55:08 +02:00
Johannes Marbach 5ad62c7097 Merge pull request #4053 from element-hq/johannes/oxlint
Switch from eslint to oxlint
2026-06-25 15:46:05 +02:00
Johannes Marbach 76714ac3ad Use the correct company name
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-25 15:29:00 +02:00
Timo K 15b40835b8 Default to compatibilty mode 2026-06-25 13:31:02 +02:00
Valere dc5b541b21 fix(voice): Default to earpiece for voice only call 2026-06-25 13:10:27 +02:00
Johannes Marbach f376ffb96b Don't redact null or undefined 2026-06-24 13:35:44 +02:00
Johannes Marbach d5ce3a955a Port over copyright rule 2026-06-24 11:08:29 +02:00
Johannes Marbach d2cb7e334e Remove duplicate paths config 2026-06-24 10:53:14 +02:00
Johannes Marbach 9d61ca13cb Use regex to test node name 2026-06-24 10:49:18 +02:00
Johannes Marbach 51bd17d335 Restrain logging of URL properties 2026-06-24 08:22:02 +02:00
Johannes Marbach 1cd2764eb2 Switch from eslint to oxlint 2026-06-23 15:13:46 +02:00
Johannes Marbach 3fb81032e0 Merge pull request #4054 from element-hq/johannes/pip-border
Add PiP border in 1on1 layouts
2026-06-23 12:38:24 +02:00
Johannes Marbach da942ea900 Fix formatting 2026-06-23 12:19:40 +02:00
Johannes Marbach d534c617d2 Eliminate noOutline constant 2026-06-23 11:42:14 +02:00
Robin ded3d0ab81 Merge pull request #4061 from element-hq/subtitle-only
Show the subtitle of the app bar on small screens
2026-06-23 10:01:55 +02:00
Robin a4b4a6b655 Remove redundant styles 2026-06-23 09:42:57 +02:00
Robin be5d8140c9 Make it clearer why the app bar elements need to be hidden individually 2026-06-23 09:42:28 +02:00
Robin 7d54487354 Merge pull request #4060 from element-hq/speaker-color
Invert the colors of the speaker toggle button
2026-06-23 09:27:39 +02:00
Johannes Marbach 93fae5dd67 Add PiP border in 1on1 layouts
Fixes: #4008
2026-06-23 08:56:41 +02:00
Robin 504888b359 Show the subtitle of the app bar on small screens
Previously we were hiding the entire app bar on mobile phones in landscape orientation. However now that the app bar supports a small 'subtitle' element, we should show only the subtitle in this case to match the designs.

The subtitle still hides on tap, just like the footer.
2026-06-22 18:21:17 +02:00
Robin 99e95658df Invert the colors of the speaker toggle button 2026-06-22 16:10:45 +02:00
Robin 5a68010022 Merge pull request #4046 from element-hq/header-subtitle
Move ringing status indicator to header on mobile
2026-06-22 13:14:55 +02:00
Robin a456e47796 Show ringing status even on spotlight tiles (except in app bar mode) 2026-06-22 12:55:37 +02:00
Robin 27abf816f5 Fix vertical alignment of ringing status icons on Chromium and Safari 2026-06-22 12:12:54 +02:00
Robin c6188a8345 Improve test coverage 2026-06-22 11:59:16 +02:00
Robin 22ff0d34b7 Merge branch 'livekit' into header-subtitle 2026-06-22 11:35:52 +02:00
Robin 96d1d1e1aa Merge pull request #4040 from element-hq/scope-leak-lint
Add lint rule to prevent ObservableScope resource leaks
2026-06-22 11:13:10 +02:00
Robin 6f75e90fcd Merge pull request #4051 from element-hq/remote-members
Clarify which Matrix-LiveKit members are remote
2026-06-22 11:05:32 +02:00
Robin f78f507745 Address remaining resource leak error 2026-06-22 10:53:08 +02:00
Robin 89c148a530 Update remaining variable names for remote Matrix-LiveKit members 2026-06-22 10:44:24 +02:00
Robin e11c04ac87 Move ringing status indicator to header on mobile
On mobile, the ringing status indicator is supposed to display in the header rather than on a tile. The exact layout differs between Android and iOS. To get it right I had to refactor AppBar to use CSS grid templates.

(Also, I changed my mind about the exact ringing data I needed out of CallViewModel - sorry. A little move of the ringtone audio renderer into its own component was necessary to accommodate that.)
2026-06-18 17:41:35 +02:00
Timo 9b070052a0 Merge pull request #4032 from element-hq/toger5/lobby-remove-minimise-add-back-button
Add primary button icon configuration and set it left arrow in lobby
2026-06-18 23:02:03 +08:00
Johannes Marbach bb3d2cd95c Merge pull request #4048 from element-hq/johannes/oxfmt
Switch from prettier to oxfmt
2026-06-18 13:06:55 +02:00
Robin d417bd8b90 Merge pull request #4052 from element-hq/auto-leave-docs
Fix docs for autoLeave URL param
2026-06-18 12:50:13 +02:00
Robin f72a593eb4 Merge branch 'livekit' into scope-leak-lint 2026-06-18 12:49:40 +02:00
Robin f6cd06fc01 Fix docs for autoLeave URL param
The parameter as seen in URLs has a different name from what's used in the code.
2026-06-18 12:14:23 +02:00
Robin e77d143ce1 Clarify which Matrix-LiveKit members are remote
It was rather confusing that matrixLivekitMembers$ gives you objects of type RemoteMatrixLivekitMembers and yet the *local* member would often be among these. I've attempted to clear this up. To my knowledge this wasn't creating any bugs.
2026-06-18 12:10:32 +02:00
Robin 256219c4bf Merge pull request #4034 from element-hq/ringing-intent
Improve internal model of ringing, expose ringing intent to call UI
2026-06-18 12:00:34 +02:00
Robin 6af72f4c90 Format 2026-06-18 11:42:02 +02:00
Robin 3a598ffe43 Restore ability to play sound with custom volume
This was missing from the revert in 8b0f5054dd.
2026-06-18 11:35:46 +02:00
Robin 05797097c6 Only consider a call accepted if the *intended* recipient picks up 2026-06-18 11:05:55 +02:00
Robin 8b0f5054dd Revert to original fix for duplicate leave sounds
My attempted simpler fix of applying takeUntil(autoLeave$) to leaveSoundEffect$ didn't work.
2026-06-18 11:05:55 +02:00
Johannes Marbach 1c760f3ec4 Merge pull request #4050 from element-hq/johannes/oxlint-f-up
Remove accidentally added file
2026-06-18 09:28:28 +02:00
Johannes Marbach 699f383b57 Remove accidentally added file
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-18 09:24:52 +02:00
Johannes Marbach 6009988d3c Merge pull request #4010 from element-hq/johannes/label-sync
Set up label sync
2026-06-18 08:50:42 +02:00
Robin 8cda9777a5 Merge branch 'livekit' into ringing-intent 2026-06-18 06:41:17 +02:00
Timo K 049c432fc3 update comment + fix tests 2026-06-17 18:48:26 +02:00
Johannes Marbach 3ea89e8275 Merge pull request #4043 from element-hq/johannes/update-plugin-react
Update @vitejs/plugin-react to 0.6.2
2026-06-17 16:39:19 +02:00
Timo K 19fd7f42b1 I cannot belive GH... Does this commit fix the processing situation? 2026-06-17 16:35:09 +02:00
Johannes Marbach 0410be33cd Merge branch 'livekit' into johannes/update-plugin-react 2026-06-17 16:06:54 +02:00
Johannes Marbach 002f9b0cea Merge pull request #4042 from element-hq/johannes/pnpm-11
Switch to pnpm 11.6.0
2026-06-17 15:52:18 +02:00
Johannes Marbach 669f4264c7 Reformat
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-17 15:35:47 +02:00
Johannes Marbach dc6511b99e Document why we override resolved versions 2026-06-17 15:33:36 +02:00
Johannes Marbach 032968ea2d Merge branch 'livekit' into johannes/pnpm-11 2026-06-17 15:07:28 +02:00
Johannes Marbach b2f02b0ff6 Switch from prettier to oxfmt 2026-06-17 12:20:42 +02:00
Timo K b31825d05b review 2026-06-16 16:54:14 +02:00
Timo K c4fe0223ab remove blur on mobile 2026-06-16 16:42:24 +02:00
Johannes Marbach 6ce79d8a52 Merge pull request #4045 from element-hq/renovate/npm-vite-vulnerability
Update dependency vite to v8.0.16 [SECURITY]
2026-06-16 15:22:21 +02:00
renovate[bot] b68abb7df4 Update dependency vite to v8.0.16 [SECURITY] 2026-06-16 12:45:26 +00:00
Timo 1281ed31df Merge pull request #3816 from element-hq/toger5/fix-depnedabot-security-alerts
Dependabot security alerts: Bump flatted and unidici
2026-06-16 20:34:17 +08:00
Timo K e7377fb104 fix prettier 2026-06-16 14:17:25 +02:00
Timo K edb4dfa9ed Merge branch 'livekit' into toger5/fix-depnedabot-security-alerts 2026-06-16 14:16:02 +02:00
Timo K 7db13e1fc0 Merge branch 'livekit' into toger5/fix-depnedabot-security-alerts 2026-06-16 14:13:16 +02:00
Timo K b9d769364d update tests 2026-06-15 14:59:39 +02:00
Timo K 164fb99ace refactor using enum vs jsx node 2026-06-15 14:42:45 +02:00
Timo K db26db266d update back arrow size 2026-06-15 14:26:24 +02:00
Johannes Marbach fc3461dd54 Set up label sync 2026-06-15 14:18:13 +02:00
Timo K 8c8c9eef24 Update LobbyView.tsx 2026-06-15 14:07:44 +02:00
Johannes Marbach df7451c2cf Update @vitejs/plugin-react to 0.6.2 2026-06-15 13:59:54 +02:00
Johannes Marbach 9537c7657c Switch to pnpm 11.6.0 2026-06-15 12:34:32 +02:00
Johannes Marbach 6c105e971e Merge pull request #4041 from element-hq/renovate/compound
Update Compound to v10.2.2
2026-06-15 08:06:20 +02:00
renovate[bot] cbbe6a812e Update Compound to v10.2.2 2026-06-15 01:03:21 +00:00
Robin 0a572a9528 Add lint rule to prevent ObservableScope resource leaks
The rule of thumb to avoid resource leaks is that you should never call ObservableScope methods in a callback unless the ObservableScope is directly passed to or created inside that callback. I had a go at codifying this as a lint rule.
2026-06-12 13:16:28 +02:00
Robin 3ef3ebe897 Merge pull request #4036 from element-hq/metadata-leak
Fix a minor resource leak with display names and avatars
2026-06-12 10:05:50 +02:00
Timo 0c284ed75b Merge pull request #4004 from element-hq/toger5/controls-api-onPipMediaOrientation
Controls api `onPipMediaOrientation`
2026-06-12 16:03:35 +08:00
TimoandJohannes Marbach 838d2f3c5d Update src/state/media/observeRtpStreamStats.ts
Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-12 09:47:13 +02:00
Timo 2a24fbcee1 Merge pull request #4038 from element-hq/toger5/fix-logging-indexDB
Fix Logging indexDB EC/EW collision
2026-06-12 15:29:33 +08:00
Ryan Emmick 9ef3499418 test: update developer settings snapshot after merge 2026-06-11 22:15:05 -05:00
Ryan Emmick 08d9982981 Merge upstream/livekit into branch 2026-06-11 22:11:21 -05:00
Timo K be8a76868b rename db 2026-06-11 18:38:54 +02:00
Johannes Marbach d1ea63587b Merge pull request #4035 from element-hq/johannes/knife-content-type
Remove deprecated @types/content-type dependency
2026-06-11 12:22:49 +02:00
Robin 96a9115ee7 Fix a minor resource leak with display names and avatars
I noticed that calls to createDisplayNameBehavior$ and createAvatarUrlBehavior$ were technically leaking resources since they reused the ObservableScope from their outer scope, which in practice lasts for the entire lifetime of the CallViewModel. This would not have had any noticeable effect unless you had other participants leave and rejoin the same call many thousands of times.
2026-06-11 12:21:07 +02:00
Robin 2ac6cdeb46 Improve internal model of ringing, expose ringing intent to call UI
I found our code's internal model of ringing a little overgrown (it had superfluous states like 'unknown') and difficult to extend with metadata or callbacks relating to ring attempts. By modeling ringing instead as a stream of ring attempts, where each attempt has an intent, a recipient, and an eventual outcome (accept/decline/timeout), I find it more natural to work with.

This makes room for a future 'try again' callback to allow ringing someone again after a timeout, and also forced me to look for a simpler solution to the duplicate leave sound effects. I exposed the intent of the ringing attempt to the call UI so I can later use it in the header.
2026-06-11 12:12:27 +02:00
Timo K 659da15cc2 more test coverage 2026-06-11 10:39:59 +02:00
Timo K 9b2e1279bc Update DeveloperSettingsTab.test.tsx.snap 2026-06-11 09:33:27 +02:00
Johannes Marbach 3af160bb02 Remove deprecated @types/content-type dependency 2026-06-11 08:36:25 +02:00
fkwp 73ccc5f483 Show typed error when matrix_2_0 mode is forced without homeserver MSC4354 support (#4017)
Show typed error when matrix_2_0 mode is forced without homeserver MSC4354 support
2026-06-10 18:48:16 +02:00
Johannes Marbach 3a824dfff0 Merge pull request #4029 from element-hq/johannes/i18next-cli
Replace deprecated i18next-parser with i18next-cli
2026-06-10 15:00:45 +02:00
Timo K 2b05c415c1 Merge branch 'livekit' into toger5/lobby-remove-minimise-add-back-button 2026-06-10 14:58:56 +02:00
Timo 7ffa585f0d Merge pull request #4027 from element-hq/johannes/knife-uuid-types
Remove deprecated @types/uuid
2026-06-10 20:51:11 +08:00
Johannes Marbach 0690387b70 Merge pull request #4009 from element-hq/johannes/raised-color
Fix missing text color for remote raised hand duration
2026-06-10 10:17:13 +02:00
Timo K ba1957ef52 tests 2026-06-09 18:44:27 +02:00
Timo K abf9022fd9 Add primary button icon configuration and set it left arrow in lobby 2026-06-09 17:17:25 +02:00
renovate[bot] 7fa3febf55 Update LiveKit client to v2.19.2 (#4031)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-06-08 21:13:59 +02:00
Johannes Marbach 11b08c9a9f Replace deprecated i18next-parser with i18next-cli 2026-06-08 15:32:34 +02:00
Johannes Marbach c1f528e120 Remove deprecated @types/uuid 2026-06-08 13:57:01 +02:00
Johannes Marbach 65bd51fe92 Merge pull request #4022 from element-hq/renovate/compound
Update Compound
2026-06-08 10:25:11 +02:00
Johannes Marbach 2dc664b323 Merge branch 'livekit' into renovate/compound 2026-06-08 10:05:00 +02:00
Johannes Marbach 65737ef4a5 Merge pull request #4023 from element-hq/renovate/livekit-client
Update LiveKit client to v2.19.1
2026-06-08 10:02:00 +02:00
Matt Van Horn 9a2fc883b6 fix: announce tab count and position in settings tab bar 2026-06-08 00:49:43 -07:00
renovate[bot] 753613dcdc Update LiveKit client to v2.19.1 2026-06-08 07:35:21 +00:00
Johannes Marbach a412d0c230 Merge branch 'livekit' into renovate/compound 2026-06-08 09:26:29 +02:00
Johannes Marbach 33e847c1a3 Merge pull request #4024 from element-hq/johannes/codecov
Update codecov-action to v7
2026-06-08 09:25:38 +02:00
Johannes Marbach ac2f5fe7d6 Update codecov-action to v7 2026-06-08 09:00:12 +02:00
Johannes Marbach 4c2c068c3a Update dev settings snapshot 2026-06-08 08:48:46 +02:00
renovate[bot] 102a7e2650 Update Compound 2026-06-08 01:07:22 +00:00
Valere Fedronic ba40490ca4 Merge pull request #4013 from element-hq/valere/fix_repeated_click_to_unmute
feat(mute): add syncing state and disable toggle during async mute
2026-06-05 12:58:23 +02:00
Valere 62a3ec8319 review: cleanup removed redundant default properties 2026-06-05 12:50:25 +02:00
Valere a430e87104 review: add test for busy behavior for Mic and Video buttons 2026-06-05 12:48:04 +02:00
Valere acc5a440c7 review: remove unneeded early return 2026-06-05 12:35:21 +02:00
fkwpandJohannes Marbach b99c8821d3 Add matrix_rtc_mode config option (#4014)
* Move MatrixRTCMode enum from settings.ts to ConfigOptions.ts

* Add matrix_rtc_mode config option

* add matrix_rtc_mode to config.sample.json

* Update src/settings/DeveloperSettingsTab.tsx

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Update src/settings/DeveloperSettingsTab.test.tsx

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* reviewer comments

---------

Co-authored-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-05 09:49:43 +00:00
Valere dc03d9b358 fix: Initial unmute is reverted 2026-06-05 10:29:36 +02:00
Valere Fedronic 3a6579f78d Merge pull request #4012 from element-hq/valere/livekit_error_boundary
feat(errors): Report livekit error as such instead of generic unknown errors
2026-06-05 09:31:46 +02:00
Valere 55b2537129 fixup: Update snapshot with button busy state 2026-06-04 19:14:12 +02:00
Valere b5b238ffd3 storybook: Add storybook for audio/video busy 2026-06-04 19:13:27 +02:00
Valere 4606373e5b feat(mute): add syncing state and disable toggle during async mute 2026-06-04 18:55:31 +02:00
Valere 4353f09e54 fixup snapshots 2026-06-04 15:48:30 +02:00
ElementRobot fc3c4bf566 Merge pull request #3911 from element-hq/actions/localazy-download
Localazy Download
2026-06-04 15:34:46 +02:00
Valere c05c523182 fixup: ensure dynamic keys are extracted by i18n parser 2026-06-04 15:30:59 +02:00
Johannes Marbach 629c012f48 Reorder entries 2026-06-04 15:17:34 +02:00
Valere e7d37f87bb feat(errors): Report livekit as such rather than like unknown errors 2026-06-04 11:54:42 +02:00
Johannes Marbach 664a10c724 Fix illegible text color for raised hand duration
Fixes: https://github.com/element-hq/voip-internal/issues/579
2026-06-04 11:33:55 +02:00
Timo K 308a042076 fix error with no media 2026-06-03 10:43:47 +02:00
Timo K b554ce4a87 Update CallViewModel.test.ts 2026-06-03 10:18:45 +02:00
Johannes Marbach 5f257da4f6 Merge pull request #4005 from element-hq/johannes/dry
Avoid usage of SWIFT_RELEASE_TOKEN in dry runs
2026-06-03 08:14:28 +02:00
Johannes Marbach 1196ba5959 Merge pull request #4000 from element-hq/johannes/pip-corners
Fix corner radius on screenshare overlay in widget mode
2026-06-03 08:12:48 +02:00
Johannes Marbach 067a70d19f Fix corner radius on screenshare overlay in widget mode
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-06-02 15:18:19 +02:00
Robin c021fc1548 Merge pull request #3995 from element-hq/toger5/fix-double-leave-sound
Fix play of second leave sound
2026-06-02 14:46:43 +02:00
Johannes Marbach 2e95c2cc15 Avoid usage of SWIFT_RELEASE_TOKEN in dry runs 2026-06-02 14:41:59 +02:00
Timo K 692a55c84c fix race 2026-06-02 14:06:28 +02:00
Timo K c5ffdea370 lint 2026-06-02 13:56:16 +02:00
Timo K 9117b40e7b fix tests 2026-06-02 13:52:43 +02:00
Timo 25a6cefa34 Merge pull request #3905 from manfrommedan/fix/promise-withresolvers-polyfill
Add Promise.withResolvers polyfill for older WebViews (Chrome < 119)
2026-06-02 19:43:12 +08:00
Robin 399b7d83da Merge pull request #3988 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.19.0
2026-06-02 11:50:10 +02:00
Timo e2128776b3 Merge pull request #3935 from element-hq/toger5/contribut-issue-first
Contributing.md explain "issue first" contribution approach
2026-06-02 17:35:56 +08:00
Timo 327a0ad9b0 Merge pull request #3951 from element-hq/valere/improve_error_message
Improve error messages for sfu auth problems
2026-06-02 17:33:56 +08:00
Robin 47326c28d3 Fix type error 2026-06-02 11:33:17 +02:00
Robin 084fa20b3d Merge pull request #4003 from element-hq/device-switch-fixes
Show the right labels in device switcher menus
2026-06-02 11:29:28 +02:00
Timo K e05af09c28 Add test 2026-06-02 11:27:59 +02:00
Robin cec78a383d Fix footer stories 2026-06-02 11:14:53 +02:00
Timo K b86909af52 temp add tests 2026-06-02 10:56:34 +02:00
Robin faa34aeaaa Merge pull request #3987 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v10.2.0
2026-06-02 10:48:44 +02:00
Robin 5fed562db2 Request full device names when device switchers are open 2026-06-02 10:36:57 +02:00
Robin 164765cd27 Show the right fallback labels in device switcher menus 2026-06-02 10:15:40 +02:00
renovate[bot] 489492d9b9 Update dependency livekit-client to v2.19.0 2026-06-02 06:50:03 +00:00
renovate[bot] bec34ee360 Update dependency @vector-im/compound-design-tokens to v10.2.0 2026-06-02 06:49:28 +00:00
Johannes Marbach 79298a6620 Merge pull request #3998 from element-hq/renovate/all-minor-patch
Update all non-major dependencies
2026-06-02 08:47:58 +02:00
Robin af8a876adc Merge pull request #3999 from element-hq/renovate/embedded-package-dependencies
Update Gradle to v8.14.5
2026-06-02 08:42:39 +02:00
Johannes Marbach f4d6517bbb Switch from global to globalThis to fix import issues 2026-06-02 08:29:03 +02:00
renovate[bot] 576119663f Update all non-major dependencies 2026-06-02 01:16:51 +00:00
Timo K d6635b7818 add docs 2026-06-01 16:13:03 +02:00
Timo K a6fc710d8c Add onPipMediaOrientationUpdate to controls api. 2026-06-01 16:12:57 +02:00
renovate[bot] e67d2f2f2b Update Gradle to v8.14.5 2026-06-01 03:52:15 +00:00
Valere 3799ea2b4e fixup: Missing translation 2026-05-28 14:43:42 +02:00
Valere 2bd16beccf Merge branch 'livekit' into valere/improve_error_message 2026-05-28 14:27:17 +02:00
Valere Fedronic 72b0d12d7c Merge pull request #3973 from element-hq/valere/devx/run_dev_on_phone
devx: Allow to run local dev EC on mobile in same wifi
2026-05-28 14:26:07 +02:00
Valere 6777adac54 Merge branch 'livekit' into valere/devx/run_dev_on_phone 2026-05-28 13:17:22 +02:00
Valere Fedronic 083f86b19d Merge pull request #3924 from element-hq/valere/devx/livekit_logs
dev-tool: Add option to enable extended livekit logs
2026-05-28 13:16:57 +02:00
Valere 1fafc3b1ba Merge branch 'livekit' into valere/devx/livekit_logs 2026-05-28 12:45:39 +02:00
Valere Fedronic cdbb2c92ca Merge pull request #3994 from element-hq/valere/devx/bump_playwright
bump playwright to 1.60
2026-05-28 12:45:19 +02:00
Timo K ea4144ccc7 Fix play of second leave sound 2026-05-28 12:04:42 +02:00
Valere 2928e34ee2 test: use toHaveCount instead of count()).toBe 2026-05-28 11:56:29 +02:00
Valere d98e800077 bump playwright
update playwright container image
2026-05-28 11:32:18 +02:00
Valere 8b86c1914d bump playwright
update playwright container image
2026-05-28 11:31:38 +02:00
Valere e815277fa2 touch commit to unbock gh 2026-05-28 08:59:05 +02:00
Valere 810e263cbe review: better doc 2026-05-28 08:51:00 +02:00
Valere 70a0914017 eslint 2026-05-27 14:18:34 +02:00
Valere 8011f2579c test: Add test for new option
review: Remove uneeed {" "}
2026-05-27 14:03:31 +02:00
Valere 187665748b Merge branch 'livekit' into valere/devx/livekit_logs 2026-05-27 11:46:38 +02:00
Timo ee1e8c60e7 Merge pull request #3961 from element-hq/toger5/view-model-call-footer-example
Implement fast switcher (+ ViewModel with snapshot example)
2026-05-20 23:38:00 +08:00
TimoandRobin a9fd98c563 Update src/state/ViewModel.ts
Co-authored-by: Robin <robin@robin.town>
2026-05-20 17:16:08 +02:00
Robin 0de6e94647 Merge pull request #3983 from element-hq/one-on-one-portrait-regressions
Fix a couple more regressions due to the one-on-one portrait layout
2026-05-20 17:03:54 +02:00
Timo K dc52573ce2 fix backgroundBlurToggleClick -> videoBlurToggleClick 2026-05-20 17:03:45 +02:00
Timo K 509e4d739f Update InCallView.test.tsx 2026-05-20 16:47:41 +02:00
Timo K 355ec4fe62 fix visibility test 2026-05-20 16:39:25 +02:00
Timo K d9c0de73b7 Merge branch 'livekit' into toger5/view-model-call-footer-example 2026-05-20 16:28:04 +02:00
Robin 8d07d552d7 Ensure landscape layout is always edge-to-edge on mobile
The spotlight landscape layout was edge-to-edge previously, and should remain that way.
2026-05-20 16:11:07 +02:00
Robin 8324ce2ce0 Avoid overlapping spotlight tiles with the app bar
The default inset that is given to the fixed layout layer needs to account for the app bar now.
2026-05-20 16:11:07 +02:00
Timo ae20f59dea Merge pull request #3978 from element-hq/app-bar-media-foreground
Ensure that foreground elements of media tile do not overlap app bar
2026-05-20 21:36:43 +08:00
Robin eaa049433a Merge pull request #3979 from element-hq/settings-footer-glitch
Stop the settings button from appearing while footer is fading out
2026-05-20 15:15:37 +02:00
Valere Fedronic 06b6122ec9 Merge pull request #3980 from element-hq/valere/fix_control_shown_in_PIP
fix(regression): control buttons should be hidden on mobile PIP
2026-05-20 15:07:24 +02:00
Timo K ec3b52f963 del: windowMode$ from public callViewModel
replace with edgeToEdge$
2026-05-20 15:00:28 +02:00
Valere FedronicandRobin 2fb5de3ba2 review: eqeqe
Co-authored-by: Robin <robin@robin.town>
2026-05-20 14:50:56 +02:00
Timo K c9fa586db1 merge fixups 2026-05-20 14:25:40 +02:00
Timo K 3301d2fe03 Merge branch 'livekit' into toger5/view-model-call-footer-example 2026-05-20 14:25:25 +02:00
Timo 3adddf4b70 Merge pull request #3960 from element-hq/toger5/storybook-tests
Use vite storybook plugin for storybook tests
2026-05-20 19:57:21 +08:00
Robin 4b175b814e Update and simplify tests 2026-05-20 13:43:33 +02:00
Timo bc3da2dda2 Merge pull request #3975 from Johennes/johannes/dev-deps
List additional dependencies needed for development
2026-05-20 19:34:25 +08:00
Timo K e7c87e3439 Merge branch 'livekit' into toger5/storybook-tests 2026-05-20 13:28:03 +02:00
Timo K ddde05c057 review 2026-05-20 13:26:52 +02:00
Valere 265781ea5e fix(regression): control buttons should be hidden on mobile PIP 2026-05-20 13:08:51 +02:00
Valere 1865110492 reproduce bug internal #578
Regression: Controls are shown in Mobile PIP
2026-05-20 13:08:11 +02:00
Robin 43f1b89535 Stop the settings button from appearing while footer is fading out
The designs actually never want us to show the settings button in the footer if an app bar is in use (as in Element X mobile apps), so just avoid showing it at all in that case.
2026-05-20 12:38:18 +02:00
Robin 284d76e333 Merge pull request #3977 from element-hq/posthog-updates
Allow PostHog dependency updates again
2026-05-20 12:22:51 +02:00
Robin 7b32135328 Ensure that foreground elements of media tile do not overlap app bar
Because the type of header that we use in Element X (an 'app bar') lives in a different place in the document than the other headers, it needs a special branch to propagate the right insets.
2026-05-20 11:55:27 +02:00
Robin 94ce207581 Allow PostHog dependency updates again 2026-05-20 11:50:01 +02:00
fkwpandTimo K f0db134b6e Posthog: drop $initial_person_info from outgoing events (#3968)
* Posthog: drop $initial_person_info from outgoing events

* Posthog: migrate from sanitize_properties to before_send

* strip URL fields from $set / $set_once

* enable mask_personal_data_properties

* review

* update tests to check for `delete` (not anymore `=null`)
rename: `applyPrivacyFilters`->`santizeSensitiveData`

---------

Co-authored-by: Timo K <toger5@hotmail.de>
2026-05-19 18:06:59 +02:00
Timo 0e3aaaafe8 Fix tap-to-show call controls on iOS (ReactMouseEvent -> ReactPointerEvent) (#3966) 2026-05-19 23:50:55 +08:00
Timo 78d76ad8a6 Bump babel/plugin-transform-modules-systemjs (#3974) 2026-05-19 17:03:25 +02:00
Robin 6b261611a5 Log unhandled promise rejections in rageshakes (#3972)
For more comprehensive rageshake logs. Copied from Element Web's rageshake.ts.
2026-05-19 13:44:16 +02:00
Johannes Marbach 6f7d4ec8eb List additional dependencies needed for development
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-19 11:14:53 +02:00
Timo K a494b64055 Update MediaMuteAndSwitchButton.test.tsx 2026-05-18 21:00:55 +02:00
Timo K e19d5865b0 cleanup 2026-05-18 20:08:39 +02:00
Timo K bc81da43c2 use previous device selection logic 2026-05-18 19:39:29 +02:00
Timo K 68a487c9de translation fixes 2026-05-18 19:17:43 +02:00
Timo K d5bebcc3a5 refactor video blur toggle 2026-05-18 18:11:32 +02:00
Timo K dd79ef659c lint 2026-05-18 16:28:25 +02:00
Timo K e22ab9355c Review (everything except translation feedback) 2026-05-18 14:31:03 +02:00
Robin f3c0b9ed8d Merge pull request #3969 from element-hq/renovate/livekit-client
Update dependency livekit-client to v2.18.10
2026-05-18 13:30:48 +02:00
Robin ac1297d02c Merge pull request #3916 from element-hq/one-on-one-portrait
Create a proper one-on-one call layout for portrait screens
2026-05-18 13:28:59 +02:00
Valere dfbb6c770e devx: Allow to run local dev EC on mobile in same wifi 2026-05-18 13:25:56 +02:00
Robin d66afd734d Merge pull request #3971 from Johennes/johannes/minor-docs-fixes
Minor documentation fixes
2026-05-18 13:17:34 +02:00
Robin cfbdbca5bb Fix media foreground elements being obscured by header/footer 2026-05-18 13:08:52 +02:00
Johannes Marbach 49f6c74818 Minor documentation fixes
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 11:12:26 +02:00
Robin d7acce49a3 Merge pull request #3970 from Johennes/johannes/typo
Minor README fixes
2026-05-18 10:12:59 +02:00
Johannes Marbach 18a128b865 Fix linking link
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:56:37 +02:00
Johannes Marbach 80f573e899 Add missing apostrophe
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:50:42 +02:00
Johannes Marbach 0ca2b6d4ea Remove extra space and add colon
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:49:58 +02:00
Johannes Marbach 198d961646 Add missing apostrophe
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:48:46 +02:00
Johannes Marbach 9d522913f9 Add missing colon
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 09:46:41 +02:00
Johannes Marbach 69b5209071 Fix typo
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:40:08 +02:00
Johannes Marbach 0160f60c6d Reorder sentence fragments for better readability
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:33:36 +02:00
Johannes Marbach abce4d16fc Add missing article
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:29:47 +02:00
Johannes Marbach b0c1c48ad4 Add missing article
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:26:42 +02:00
Johannes Marbach a2c6b8102a Link to self-hosting guide
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:21:17 +02:00
Johannes Marbach 959a55a9f8 Use correct preposition
Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2026-05-18 08:21:00 +02:00
renovate[bot] 4452f3c0c6 Update dependency livekit-client to v2.18.10 2026-05-18 00:39:52 +00:00
Timo K 88f660e43f review 2026-05-15 18:37:25 +02:00
fkwp 04b0165961 Fix tap-to-show call controls on iOS (ReactMouseEvent -> ReactPointerEvent) 2026-05-15 12:29:26 +02:00
Robin 7e932d27da Merge pull request #3939 from element-hq/renovate/github-actions
Update GitHub Actions
2026-05-15 11:42:37 +02:00
Robin a1e03ea389 Merge pull request #3942 from element-hq/renovate/compound
Update dependency @vector-im/compound-design-tokens to v10.1.1
2026-05-15 11:36:20 +02:00
Robin de52ea6590 Fix tests 2026-05-15 11:29:22 +02:00
Robin 9ad5d02e31 Merge branch 'livekit' into one-on-one-portrait 2026-05-15 11:28:47 +02:00
Robin f3234b1c3e Merge branch 'livekit' into renovate/github-actions 2026-05-15 11:26:25 +02:00
Robin 53acd2596c Merge branch 'livekit' into renovate/compound 2026-05-15 11:20:55 +02:00
cec3a799af Posthog add reconnect event (#3953)
* Add Posthog events for Call reconnect including the reason

* Expose single trackCallReconnecting() entry point on PosthogAnalytics

* Track reconnect duration and align with existing analytics pattern

* Refactor combined$ to return [connected, reason] tuple

* Update firefoxUserPrefs to allow getUserMedia and enumerateDevices on CI
---------

Co-authored-by: Valere <bill.carson@valrsoft.com>
Co-authored-by: Robin <robin@robin.town>
Co-authored-by: Timo K <toger5@hotmail.de>
2026-05-14 23:07:02 +02:00
Timo K e10bc6c7cf fix unit tests 2026-05-13 13:35:12 +02:00
Timo K 0082ade234 reuse exisng mute buttons (fixes most playwright issues) 2026-05-13 13:16:24 +02:00
Robin b562a0f721 Create a proper one-on-one call layout for portrait screens 2026-05-13 09:54:58 +02:00
renovate[bot] 77f4704b20 Update dependency @vector-im/compound-design-tokens to v10.1.1 2026-05-12 15:58:30 +00:00
renovate[bot] 53e9d05587 Update GitHub Actions 2026-05-12 15:57:48 +00:00
Valere Fedronic b9f73e3e9a Merge pull request #3962 from element-hq/valere/fix_double_unmute_bug
bugfix: When starting muted, have to click twice to unmute in call
2026-05-12 17:28:27 +02:00
Timo K 07e7090cc7 fix unit tests 2026-05-12 16:50:45 +02:00
Timo K 7ad8f9f182 Fix tests (accessibility issues with new buttons) 2026-05-12 16:48:08 +02:00
Valere 408a38a7d6 bugfix: When starting muted, have to click twice to unmute in call 2026-05-12 15:46:38 +02:00
Timo K 7842f5a8d0 test coverage? 2026-05-12 14:11:48 +02:00
Timo K c3cd6248ae simple lobby snapshot tests
(we dont want this to get too invested as we plan to refactor the lobby(
2026-05-12 13:14:34 +02:00
Timo K f3ef7c074b lint 2026-05-12 12:52:23 +02:00
Timo K 246db5a820 simplifications docs and tests 2026-05-12 12:46:36 +02:00
Timo K b042f2594d fix lints and format 2026-05-11 20:09:55 +02:00
Timo K 102542c1b0 update tests 2026-05-11 20:02:56 +02:00
Timo K ff3b4c6ff4 cleanups 2026-05-11 20:02:56 +02:00
Timo K 7514bd4811 use footer view model in InCallview 2026-05-11 20:02:56 +02:00
Timo K d29b920581 use footer view model in lobby 2026-05-11 20:02:56 +02:00
Timo K 936dfb193f add the CallFooterViewModel 2026-05-11 20:02:56 +02:00
Timo K 7615e146a5 Use ViewModel approach for the CallFooterView + interaction tests
(this is just imaginary. There is no view model yet.)
2026-05-11 20:02:56 +02:00
Timo K 64d5e8ca24 Introduce ViewModel.ts 2026-05-11 20:02:56 +02:00
Timo K 9f6ae4ba55 small device selection refactor 2026-05-11 20:02:56 +02:00
Timo K d37c064df3 custom container does not work for playwright tests 2026-05-11 20:01:18 +02:00
Timo K 34486e6486 use container to skip playwright install 2026-05-11 19:57:36 +02:00
Timo K ded6e4c1a0 fix tests dep optimization issues 2026-05-11 19:50:32 +02:00
Timo K 5bdbd80f37 try storybook only 2026-05-11 19:29:07 +02:00
Timo K 4f28bac749 without playwright container 2026-05-11 19:18:10 +02:00
Timo K f37128414a try with firefox 2026-05-11 19:16:07 +02:00
Timo K 7ca890ea53 Delete vitest.setup.ts 2026-05-11 19:08:45 +02:00
Timo K 85762001a4 try file parallelism 2026-05-11 19:08:40 +02:00
Timo K aa125d917d no setup file for storybook 2026-05-11 19:01:47 +02:00
Timo K 88f14ffcde try with playwirght container 2026-05-11 18:56:52 +02:00
Timo K bc2be4dfab test in CI 2026-05-11 18:44:16 +02:00
Timo K 53626785b3 install playwright for unit tests 2026-05-11 18:22:58 +02:00
Valere f1b61a9f1c update developer settings tab snapshot 2026-05-11 18:07:40 +02:00
Valere 2ac221661b review: Use Initializer instead of creating a component 2026-05-11 17:43:34 +02:00
Valere 25ac565f8b Merge branch 'livekit' into valere/devx/livekit_logs 2026-05-11 17:28:07 +02:00
Timo K 20d14fa4e0 use vite storybook plugin for storybook tests.
run via pnpm test:storybook
2026-05-11 16:40:05 +02:00
Timo K 5dfd1648c5 remove yarn lock 2026-05-11 16:30:57 +02:00
Timo K 1d5ac67324 Merge branch 'livekit' into toger5/fix-depnedabot-security-alerts 2026-05-11 16:28:48 +02:00
Timo f4ff790d2c Add MediaMuteAndSwitchButton component (storybook only) (#3938)
* add MeidaMuteAndSwitchButton

* User button in footer

* Add tests

* update styling (dark bg on menu open + chevron white + chevron up)

* fix tests

* add storybook to CI

only add storybook with storybook label

test names

another env name test

TestName

new default name

remove label condition

Update pr-deploy.yaml

* Update pr-deploy.yaml

* add toggle example to default component

* hook up footer select actions

* fix video audio button (swapped) and lable in story
2026-05-11 11:50:33 +02:00
Robin bd2de29470 Merge pull request #3922 from element-hq/hs/compound-switch
Use Compound Switch component
2026-05-11 09:51:56 +02:00
Valere d0bfef0682 Improve error messages for sfu auth problems 2026-05-07 16:21:30 +02:00
Timo K 121d87b1d0 Merge branch 'livekit' into toger5/contribut-issue-first 2026-05-05 14:39:52 +02:00
Timo K d5b218283d add max reply SLA 2026-04-30 13:14:13 +02:00
Timo K d4a3c8bd80 Contributing.md update. Issue First policy 2026-04-27 13:57:38 +02:00
Valere 3bc8c0590d dev-tool: Add option to enable extended livekit logs 2026-04-24 18:27:05 +02:00
Ryan Emmick 79b9335192 fix: update i18n translations and developer settings snapshot
Run `yarn i18n` to extract new translation keys for the media quality
settings, and update the DeveloperSettingsTab snapshot to include the
Camera quality, Screen sharing, and Audio processing sections.
2026-04-23 09:51:01 -05:00
Ryan Emmick dedf4e158e refactor: move media settings to developer tab, centralize config defaults 2026-04-23 09:49:14 -05:00
Ryan Emmick 8642646fa7 Add unit tests, revert vite base path change
Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
2026-04-23 09:47:58 -05:00
Ryan Emmick 98f63c7172 Clean up: deduplicate settings UI, fix backupCodec, remove dead export
Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
2026-04-23 09:47:58 -05:00
Ryan Emmick 297470ac2e Use relative base path for vite build
Allows Element Call to be served from a subdirectory (e.g. /widgets/element-call/
in Element Web) without breaking dynamic imports for locales, workers, and other
assets that were previously using absolute paths.

Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
2026-04-23 09:47:58 -05:00
Ryan Emmick b043230680 feat: camera quality settings, audio processing toggles, config-seeded defaults
- Add camera video quality controls (resolution/framerate/bitrate/codec)
  to Settings > Video, mirroring the screen share settings UI
- Add audio processing toggles (echo cancellation, noise suppression,
  auto gain control) to Settings > Audio, replacing URL-param-only controls
- Display raw values inline on all sliders (framerate, bitrate, volume)
- Add config-seeded defaults: config.json media_quality values now seed
  Setting defaults for users who haven't explicitly set preferences
- Camera settings are applied when joining a call via ConnectionFactory

Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
2026-04-23 09:47:58 -05:00
Ryan Emmick bc38d15b6d fix: replace InputField type="select" with native select elements
InputField only supports input/textarea, not select. Passing option
children caused React to crash rendering children inside a void input.

Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
2026-04-23 09:46:47 -05:00
Ryan Emmick 342b61f633 feat: add user-configurable screen share quality settings UI
Adds a "Screen sharing" section to Settings > Video with controls for:
- Resolution (576p to 4K)
- Framerate (5-60 fps slider)
- Bitrate (0.5-15 Mbps slider)
- Codec (VP8/VP9/H.264/AV1)

Gated behind an "Advanced screen share settings" toggle. When enabled,
settings are passed to LiveKit's setScreenShareEnabled as both capture
constraints and publish options. When disabled, falls back to
config.json media_quality defaults.

Settings are persisted in localStorage via the existing Setting<T>
system. The Slider component is extended with a tooltipFormatter prop
for custom tooltip display.

Inspired by pirosuki's advanced-screen-share-settings branch, but
reimplemented cleanly: settings are read directly in LocalMember.ts
(no signature changes), the existing Slider is extended (no component
duplication), and proper form components are used throughout.

Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
2026-04-23 09:46:47 -05:00
Ryan Emmick 390dc22c96 feat: configurable media quality via config.json
Add a `media_quality` section to config.json that allows self-hosters
to configure video codec, resolution, bitrate, framerate, and simulcast
layers for both camera and screen sharing.

This addresses the long-standing request in #249 for configurable media
quality settings. The LiveKit SDK already supports all of these options;
this change exposes them through the existing config system.

New config.json fields:
- media_quality.video_codec: preferred codec (vp8/vp9/h264/av1)
- media_quality.video: camera resolution, bitrate, framerate, simulcast layers
- media_quality.screen_share: screen share resolution, bitrate, framerate,
  simulcast layers (enables 3+ layer simulcast for screen sharing)

All fields are optional and fall back to the existing defaults (VP8,
720p camera, 1080p screen share) when not specified.

Signed-off-by: Ryan Emmick <ryanemmick4@gmail.com>
2026-04-23 09:46:47 -05:00
fkwp f919cfa4c8 Translations updates 2026-04-22 12:29:57 +00:00
manfrommedan d1b14d8bfb Add Promise.withResolvers polyfill for older WebViews (Chrome < 119) 2026-04-21 23:29:47 +03:00
Timo K 2faab3280a bump flatted and unidici 2026-03-25 16:07:01 +01:00
Timo 2a3271b8b3 Merge branch 'livekit' into defaut-server-config 2025-04-04 23:27:45 +02:00
Hugh Nimmo-Smith f45aa85aef Remove default server config as it is not likely to be correct 2025-03-10 18:14:25 +00:00
612 changed files with 56450 additions and 21877 deletions
+127
View File
@@ -0,0 +1,127 @@
---
name: install-anti-slop
description: Install, configure, update, or upgrade vendored anti-slop Oxlint plugins. Use when adding anti-slop, picking up upstream rules or fixes, or migrating an existing installation while preserving local customizations.
---
# Install or update anti-slop
Anti-slop is vendored code: the target repository owns its rules, diagnostics, tests, and configuration. Preserve those choices when bringing in upstream changes.
## Choose the path
Read the repository's agent instructions and `git status`. Identify its package manager, Oxlint/Vite+ configuration, and any existing anti-slop entry points, including renamed or relocated copies referenced by `jsPlugins`.
- **Existing installation — update, upgrade, migrate, or reconfigure:** read [Update a vendored installation](references/update.md) and follow that procedure instead of the fresh-install steps below.
- **No installation — fresh install:** follow the procedure below. If the user requested an update but no installation can be found, confirm the target before installing.
Complete when the operation and target path are established and pre-existing work is identified.
## Fresh install
1. Copy the bundled plugin from this skill. Run from the target repository:
```bash
node <skill-directory>/scripts/install.mjs
```
This creates `tools/oxlint/anti-slop/`. Pass another relative destination as the first argument when the repository has an established tooling layout. The script refuses to replace an existing destination; route existing copies through the update procedure rather than `--force`.
Preserve the nested `vendor/eslint-stylistic/LICENSE` and `UPSTREAM.md`; they travel with the copied rule. Readability enforcement is self-contained and requires no Stylistic plugin dependency.
Complete when the files, including vendored license and provenance, exist at the agreed destination without replacing an existing copy.
2. Install current compatible dependencies rather than trusting versions remembered by the agent:
- If the repository already depends on `oxlint`, read its installed version from the package manager or lockfile and install `@oxlint/plugins` at exactly that version. Pin it exactly rather than by range so future upgrades move both packages together.
- Only when the repository has no `oxlint` dependency, query `npm view oxlint version` and `npm view @oxlint/plugins version`, then install the same current version of both packages.
- `oxlint` is a development dependency. The copied source imports `@oxlint/plugins`, so install it as a development dependency for a local-only plugin.
- Do not replace the package manager or rewrite unrelated dependency ranges.
Complete when matching compatible versions are installed and unrelated dependency ranges are preserved.
3. Register the generic plugin, configure ignores, and enable all generic rules. For `oxlint.config.ts` or `.oxlintrc.json`, merge these fields with the existing configuration:
```ts
ignorePatterns: [
".agent/**",
".agents/**",
".claude/**",
".codex/**",
".continue/**",
".cursor/**",
".gemini/**",
".opencode/**",
".pi/**",
".roo/**",
".windsurf/**",
"tools/oxlint/anti-slop/**",
],
jsPlugins: [
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
],
```
Keep every existing ignore. Adjust the final pattern when the plugin was copied elsewhere. Inspect the repository for other project-local agent tooling directories and add them rather than linting installed skills, hooks, or generated agent configuration as application source. Do not broadly ignore all dot-directories, because some repositories keep owned source or checks in them.
For Vite+, add these fields to `lint.ignorePatterns` and `lint.jsPlugins`. Also merge the same patterns into `fmt.ignorePatterns` so `vp check` does not reformat installed agent assets or the vendored plugin. Merge existing entries instead of replacing them.
Enable these rules at `"error"`, including the native Oxlint companion rule:
```json
{
"oxc/no-accumulating-spread": "error",
"anti-slop/no-array-filter-map": "error",
"anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-conditional-empty-object-spread": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-module-mocking": "error",
"anti-slop/no-object-parameters": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "error",
"anti-slop/no-shape-in-symbol-names": "error",
"anti-slop/no-unknown-parameters": "error",
"anti-slop/no-unknown-returns": "error",
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/require-readable-spacing": "error",
"anti-slop/require-safety-comment-for-type-assertion": "error"
}
```
For `no-array-filter-map`, prefer lazy `.values().filter(...).map(...).toArray()` pipelines only when the target runtime supports iterator helpers; otherwise use an appropriate single `flatMap` or locally mutating reducer. Review callback order, indexes, sparse arrays, `thisArg`, and filtering semantics rather than mechanically rewriting chains. Unknown receiver types are deliberately not inferred by this AST/scope rule.
Pair `no-reduce-accumulator-copy` with native `oxc/no-accumulating-spread`: the custom rule catches supported non-spread copies such as `Object.assign({}, acc, item)`, `Array.from(acc)`, and array accumulator `concat`/`slice` calls. Mutating a fresh local accumulator is allowed; copying individual input items is also allowed. Named callbacks, indirect helpers, and nested accumulator properties are not fully analyzed, so do not claim all quadratic reducers are ruled out.
If the repository declares `effect` in a package manifest, or the user explicitly requests Effect rules, also register the opt-in Effect plugin:
```ts
jsPlugins: [
{
name: "anti-slop-effect",
specifier: "./tools/oxlint/anti-slop/effect/index.ts",
},
],
rules: {
"anti-slop-effect/no-manual-effect-error-tag": "error",
"anti-slop-effect/no-manual-tag-comparison": "error",
"anti-slop-effect/no-manual-tagged-construction": "error",
"anti-slop-effect/no-service-constructor-imports": "error",
"anti-slop-effect/prefer-effect-match": "error",
},
```
Merge these entries with the generic plugin configuration rather than replacing it. Do not enable the Effect plugin merely because Effect appears transitively in a lockfile; require a direct package-manifest dependency or an explicit user request. The rule covers relative project imports. Report package-alias imports as a current limitation rather than pretending they are enforced.
Complete when the generic rules and eligible Effect rules are registered and existing configuration is preserved.
4. Run the repository's lint command and typecheck. For Vite+, run the repository's full `vp check` command after adding both lint and format ignores. If findings appear in owned project source, report them and fix them only when the user asked for migration/cleanup. Do not suppress rules, weaken rule severity, add unsafe casts, or mechanically launder types to make lint pass.
When cleanup is authorized, apply `require-readable-spacing` with lint autofix, then run the repository's formatter and lint again. Confirm a second fix/format pass leaves files unchanged. Keep whitespace fixes separate from semantic edits, preserve documentation attachment and overload groups, and do not enable an entire competing formatting preset.
Complete when checks have run, fix/format stability has been verified for authorized cleanup, and every failure is resolved or reported with its diagnostics.
5. Record provenance in `UPSTREAM.md` beside the vendored entry point: source repository, exact source commit or recoverable pristine snapshot when available, installed plugin paths, and intentional deviations. Verify that the revision identifies the actual copied assets; a package version or the current upstream HEAD alone is insufficient. If provenance cannot be established, record it as unknown rather than guessing.
Review the final diff and report the installed path, source identity, dependency/configuration changes, and check results. Complete when the record and report describe the files actually installed and any remaining findings.
@@ -0,0 +1,21 @@
import { eslintCompatPlugin } from "@oxlint/plugins";
import { noManualEffectErrorTagRule } from "./rules/no-manual-effect-error-tag.ts";
import { noManualTagComparisonRule } from "./rules/no-manual-tag-comparison.ts";
import { noManualTaggedConstructionRule } from "./rules/no-manual-tagged-construction.ts";
import { noServiceConstructorImportsRule } from "./rules/no-service-constructor-imports.ts";
import { preferEffectMatchRule } from "./rules/prefer-effect-match.ts";
/** Opt-in Oxlint rules for Effect service and Layer architecture. */
const antiSlopEffectPlugin = eslintCompatPlugin({
meta: { name: "anti-slop-effect" },
rules: {
"no-manual-effect-error-tag": noManualEffectErrorTagRule,
"no-manual-tag-comparison": noManualTagComparisonRule,
"no-manual-tagged-construction": noManualTaggedConstructionRule,
"no-service-constructor-imports": noServiceConstructorImportsRule,
"prefer-effect-match": preferEffectMatchRule,
},
});
export default antiSlopEffectPlugin;
@@ -0,0 +1,52 @@
import { defineRule } from "@oxlint/plugins";
import {
isInsideBroadEffectHandler,
isReasonTagMember,
isTagMember,
tagMemberFromComparison,
} from "../shared/tagged-values.ts";
export const noManualEffectErrorTagRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Use Effect tagged error handlers instead of manually branching on `_tag` in a catch handler.",
},
messages: {
tag: "Use Effect.catchTag or Effect.catchTags instead of manually discriminating a tagged error in a broad Effect catch handler.",
reason:
"Use Effect.catchReason or Effect.catchReasons instead of manually discriminating a tagged `reason` in a broad Effect catch handler.",
},
},
createOnce(context) {
return {
BinaryExpression(node) {
const tagMember = tagMemberFromComparison(node);
if (
tagMember === undefined ||
!isInsideBroadEffectHandler(node)
) {
return;
}
context.report({
node,
messageId: isReasonTagMember(tagMember) ? "reason" : "tag",
});
},
SwitchStatement(node) {
if (
!isTagMember(node.discriminant) ||
!isInsideBroadEffectHandler(node)
) {
return;
}
context.report({
node,
messageId: isReasonTagMember(node.discriminant) ? "reason" : "tag",
});
},
};
},
});
@@ -0,0 +1,45 @@
import { defineRule } from "@oxlint/plugins";
import {
isInsideBroadEffectHandler,
isTagMember,
tagMemberFromComparison,
} from "../shared/tagged-values.ts";
export const noManualTagComparisonRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Use Effect Match or Predicate helpers instead of manually branching on `_tag`.",
},
messages: {
manualComparison:
"Use Match.tag/Match.tags for tagged-value branching, or Predicate.isTagged for a simple reusable predicate.",
manualSwitch:
"Use Match.value(value).pipe(Match.tag/Match.tags/Match.tagsExhaustive) or the tagged enum `$match` helper instead of switching on `_tag`.",
},
},
createOnce(context) {
return {
BinaryExpression(node) {
if (
tagMemberFromComparison(node) === undefined ||
isInsideBroadEffectHandler(node)
) {
return;
}
context.report({ node, messageId: "manualComparison" });
},
SwitchStatement(node) {
if (
!isTagMember(node.discriminant) ||
isInsideBroadEffectHandler(node)
) {
return;
}
context.report({ node, messageId: "manualSwitch" });
},
};
},
});
@@ -0,0 +1,37 @@
import { defineRule } from "@oxlint/plugins";
import {
isMatchPatternObject,
isStringLiteral,
propertyName,
} from "../shared/tagged-values.ts";
export const noManualTaggedConstructionRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Construct tagged values with their existing Effect constructor instead of writing `_tag` manually.",
},
messages: {
manualConstruction:
"Use the existing Schema tagged `.make`, tagged class/error constructor, or Data.taggedEnum variant constructor instead of writing a literal `_tag` object.",
},
},
createOnce(context) {
return {
ObjectExpression(node) {
if (isMatchPatternObject(node)) return;
const tag = node.properties.find(
(property) =>
property.type === "Property" &&
propertyName(property) === "_tag" &&
isStringLiteral(property.value),
);
if (tag !== undefined) {
context.report({ node: tag, messageId: "manualConstruction" });
}
},
};
},
});
@@ -0,0 +1,52 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
const SERVICE_CONSTRUCTOR_NAME = /^make[A-Z]/u;
const TEST_FILE = /\.(?:test|spec)\.[cm]?[jt]sx?$/u;
function isProjectLocalImport(source: string): boolean {
return source.startsWith("./") || source.startsWith("../");
}
function getImportedName(specifier: ESTree.ImportSpecifier): string {
if (specifier.imported.type === "Identifier") return specifier.imported.name;
return specifier.imported.value;
}
/** Keep dependency-bearing Effect service constructors local to their owning capability modules. */
export const noServiceConstructorImportsRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow project-local make<CapabilityName> imports outside test and spec files.",
},
messages: {
serviceConstructorImport:
'Do not import Effect service constructor "{{name}}" into runtime code. Import the owning Layer, yield the contextual service, and allow its requirements to propagate to the composition root.',
},
},
create(context) {
const isTestFile = TEST_FILE.test(context.filename.replaceAll("\\", "/"));
return {
ImportDeclaration(node) {
if (isTestFile || !isProjectLocalImport(node.source.value)) return;
for (const specifier of node.specifiers) {
if (specifier.type !== "ImportSpecifier") continue;
const importedName = getImportedName(specifier);
if (!SERVICE_CONSTRUCTOR_NAME.test(importedName)) continue;
context.report({
node: specifier,
messageId: "serviceConstructorImport",
data: { name: importedName },
});
}
},
};
},
});
@@ -0,0 +1,54 @@
import { defineRule, type ESTree } from "@oxlint/plugins";
const equalityOperators = new Set(["==", "===", "!=", "!=="]);
export const preferEffectMatchRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Use Match from Effect for chained literal ternaries over the same value.",
},
messages: {
preferMatch:
"Use Match from Effect instead of a chained literal ternary.",
},
},
createOnce(context) {
const isLiteral = (node: ESTree.Node): boolean =>
node.type === "Literal" ||
(node.type === "TemplateLiteral" && node.expressions.length === 0);
const comparedValue = (node: ESTree.Expression): string | undefined => {
if (
node.type !== "BinaryExpression" ||
!equalityOperators.has(node.operator)
) {
return undefined;
}
if (isLiteral(node.left)) return context.sourceCode.getText(node.right);
if (isLiteral(node.right)) return context.sourceCode.getText(node.left);
return undefined;
};
return {
ConditionalExpression(node) {
if (node.parent?.type === "ConditionalExpression") return;
const value = comparedValue(node.test);
if (value === undefined) return;
let alternate = node.alternate;
let literalChecks = 1;
while (alternate.type === "ConditionalExpression") {
if (comparedValue(alternate.test) !== value) return;
literalChecks += 1;
alternate = alternate.alternate;
}
if (literalChecks > 1) {
context.report({ node, messageId: "preferMatch" });
}
},
};
},
});
@@ -0,0 +1,97 @@
import type { ESTree } from "@oxlint/plugins";
const equalityOperators = new Set(["==", "===", "!=", "!=="]);
const broadEffectCatchMethods = new Set(["catch", "catchAll", "catchIf"]);
export const isStringLiteral = (
node: ESTree.Node | null | undefined,
): node is ESTree.StringLiteral =>
node?.type === "Literal" && typeof node.value === "string";
export const isTagMember = (
node: ESTree.Node | null | undefined,
): node is ESTree.MemberExpression =>
node?.type === "MemberExpression" &&
((!node.computed &&
node.property.type === "Identifier" &&
node.property.name === "_tag") ||
(node.computed &&
isStringLiteral(node.property) &&
node.property.value === "_tag"));
export const tagMemberFromComparison = (
node: ESTree.BinaryExpression,
): ESTree.MemberExpression | undefined => {
if (!equalityOperators.has(node.operator)) return undefined;
if (isTagMember(node.left) && isStringLiteral(node.right)) return node.left;
if (isTagMember(node.right) && isStringLiteral(node.left)) return node.right;
return undefined;
};
const isBroadEffectCatchCall = (
node: ESTree.Node | null | undefined,
): node is ESTree.CallExpression =>
node?.type === "CallExpression" &&
node.callee.type === "MemberExpression" &&
node.callee.object.type === "Identifier" &&
node.callee.object.name === "Effect" &&
!node.callee.computed &&
node.callee.property.type === "Identifier" &&
broadEffectCatchMethods.has(node.callee.property.name);
export const isInsideBroadEffectHandler = (node: ESTree.Node): boolean => {
let current: ESTree.Node | null | undefined = node.parent;
while (current !== null && current !== undefined) {
if (
current.type === "ArrowFunctionExpression" ||
current.type === "FunctionExpression"
) {
return (
isBroadEffectCatchCall(current.parent) &&
current.parent.arguments.includes(current)
);
}
current = current.parent;
}
return false;
};
export const isReasonTagMember = (node: ESTree.MemberExpression): boolean =>
node.object.type === "MemberExpression" &&
((!node.object.computed &&
node.object.property.type === "Identifier" &&
node.object.property.name === "reason") ||
(node.object.computed &&
isStringLiteral(node.object.property) &&
node.object.property.value === "reason"));
export const propertyName = (
property: ESTree.ObjectProperty,
): string | undefined => {
if (!property.computed && property.key.type === "Identifier") {
return property.key.name;
}
if (
property.key.type === "Literal" &&
typeof property.key.value === "string"
) {
return property.key.value;
}
return undefined;
};
export const isMatchPatternObject = (node: ESTree.ObjectExpression): boolean => {
const call = node.parent;
if (call?.type !== "CallExpression" || !call.arguments.includes(node)) {
return false;
}
const callee = call.callee;
return (
callee.type === "MemberExpression" &&
callee.object.type === "Identifier" &&
callee.object.name === "Match" &&
!callee.computed &&
callee.property.type === "Identifier" &&
(callee.property.name === "when" || callee.property.name === "not")
);
};
@@ -0,0 +1,47 @@
import { eslintCompatPlugin } from "@oxlint/plugins";
import { noArrayFilterMapRule } from "./rules/no-array-filter-map.ts";
import { noReduceAccumulatorCopyRule } from "./rules/no-reduce-accumulator-copy.ts";
import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts";
import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts";
import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts";
import { noModuleMockingRule } from "./rules/no-module-mocking.ts";
import { noObjectParametersRule } from "./rules/no-object-parameters.ts";
import { noReflectApplyRule } from "./rules/no-reflect-apply.ts";
import { noReflectGetRule } from "./rules/no-reflect-get.ts";
import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts";
import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts";
import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts";
import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts";
import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts";
import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts";
import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts";
import { requireReadableSpacingRule } from "./rules/require-readable-spacing.ts";
import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts";
/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */
const antiSlopPlugin = eslintCompatPlugin({
meta: { name: "anti-slop" },
rules: {
"no-array-filter-map": noArrayFilterMapRule,
"no-reduce-accumulator-copy": noReduceAccumulatorCopyRule,
"no-chained-type-assertions": noChainedTypeAssertionsRule,
"no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule,
"no-known-value-widening": noKnownValueWideningRule,
"no-module-mocking": noModuleMockingRule,
"no-object-parameters": noObjectParametersRule,
"no-reflect-apply": noReflectApplyRule,
"no-reflect-get": noReflectGetRule,
"no-runtime-typeof": noRuntimeTypeofRule,
"no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule,
"no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule,
"no-unknown-parameters": noUnknownParametersRule,
"no-unknown-returns": noUnknownReturnsRule,
"no-unknown-type-aliases": noUnknownTypeAliasesRule,
"no-widen-then-assert": noWidenThenAssertRule,
"require-readable-spacing": requireReadableSpacingRule,
"require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule,
},
});
export default antiSlopPlugin;
@@ -0,0 +1,28 @@
import { defineRule } from "@oxlint/plugins";
import { arrayMethodTarget, isKnownArrayExpression, unwrapArrayExpression } from "../shared/array-method.ts";
/** Reject eager array filter/map pipelines; lazy iterator helpers remain allowed. */
export const noArrayFilterMapRule = defineRule({
meta: {
type: "suggestion",
docs: { description: "Disallow adjacent array filter/map passes in favor of lazy iterator helpers or a single transformation." },
messages: {
arrayFilterMap: "Avoid consecutive array `{{first}}` and `{{second}}` passes. Prefer `.values().{{first}}(...).{{second}}(...).toArray()` where iterator helpers are supported, or a single `flatMap`/mutating reducer. Preserve callback ordering, indexes, and filtering semantics.",
},
},
createOnce(context) {
return {
CallExpression(node) {
const outer = arrayMethodTarget(node.callee);
if (outer === null || (outer.name !== "map" && outer.name !== "filter")) return;
const innerCall = unwrapArrayExpression(outer.object);
if (innerCall.type !== "CallExpression") return;
const inner = arrayMethodTarget(innerCall.callee);
if (inner === null || inner.name !== (outer.name === "map" ? "filter" : "map")) return;
if (!isKnownArrayExpression(context.sourceCode, inner.object)) return;
context.report({ node, messageId: "arrayFilterMap", data: { first: inner.name, second: outer.name } });
},
};
},
});
@@ -0,0 +1,77 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression {
return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
}
function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression {
let current = expression;
while (current.type === "ParenthesizedExpression") {
current = current.expression;
}
return current;
}
function isConstAssertion(node: TypeAssertionExpression): boolean {
const { typeAnnotation } = node;
return (
typeAnnotation.type === "TSTypeReference" &&
typeAnnotation.typeName.type === "Identifier" &&
typeAnnotation.typeName.name === "const"
);
}
function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean {
let current: ESTree.Expression = node;
let parent = node.parent;
while (parent.type === "ParenthesizedExpression" && parent.expression === current) {
current = parent;
parent = parent.parent;
}
return !isTypeAssertionExpression(parent) || parent.expression !== current;
}
function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean {
let assertionCount = 0;
let hasNonConstAssertion = false;
let current: ESTree.Expression = node;
while (isTypeAssertionExpression(current)) {
assertionCount += 1;
hasNonConstAssertion ||= !isConstAssertion(current);
current = unwrapParenthesizedExpression(current.expression);
}
return assertionCount > 1 && hasNonConstAssertion;
}
/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */
export const noChainedTypeAssertionsRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.",
},
messages: {
chained:
"This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.",
},
},
createOnce(context) {
const checkTypeAssertion = (node: TypeAssertionExpression) => {
if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return;
context.report({ node, messageId: "chained" });
};
return {
TSAsExpression: checkTypeAssertion,
TSTypeAssertion: checkTypeAssertion,
};
},
});
@@ -0,0 +1,49 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
function unwrapParentheses(node: ESTree.Expression): ESTree.Expression {
let current = node;
while (current.type === "ParenthesizedExpression") {
current = current.expression;
}
return current;
}
function isEmptyObjectExpression(node: ESTree.Expression): boolean {
return node.type === "ObjectExpression" && node.properties.length === 0;
}
function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean {
const conditional = unwrapParentheses(node);
return (
conditional.type === "ConditionalExpression" &&
(isEmptyObjectExpression(conditional.consequent) ||
isEmptyObjectExpression(conditional.alternate))
);
}
/** Ban conditional empty-object spreads without changing their omission semantics. */
export const noConditionalEmptyObjectSpreadRule = defineRule({
meta: {
type: "suggestion",
docs: {
description:
"Disallow object spreads that conditionally spread an empty object to omit fields.",
},
messages: {
avoid:
"This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.",
},
},
createOnce(context) {
return {
SpreadElement(node) {
if (node.parent.type !== "ObjectExpression") return;
if (isConditionalEmptyObjectSpread(node.argument)) {
context.report({ node, messageId: "avoid" });
}
},
};
},
});
@@ -0,0 +1,415 @@
import { defineRule } from "@oxlint/plugins";
import {
classifyUnsafeDictionaryValue,
classifyWideningTarget,
createTypeEnvironment,
isKnownEvidenceExpression,
type TypeEnvironment,
type WideningTarget,
} from "../shared/dictionary-types.ts";
import {
containsUnknownType,
functionParameterBindingName,
functionParameterTypeAnnotation,
} from "../shared/function-parameters.ts";
import { resolveVariable } from "../shared/scope.ts";
import type { ESTree, SourceCode, Variable } from "@oxlint/plugins";
type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function;
function unwrapExpression(expression: ESTree.Expression): ESTree.Expression {
let current = expression;
while (
current.type === "ParenthesizedExpression" ||
current.type === "TSAsExpression" ||
current.type === "TSSatisfiesExpression" ||
current.type === "TSTypeAssertion" ||
current.type === "TSNonNullExpression"
) {
current = current.expression;
}
return current;
}
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
if (variable.defs.length !== 1) return null;
const [definition] = variable.defs;
return definition?.type === "Variable" && definition.node.type === "VariableDeclarator"
? definition.node
: null;
}
function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean {
return (
declarator.parent.type === "VariableDeclaration" &&
declarator.parent.kind === "const" &&
variable.references.every((reference) => reference.init || !reference.isWrite())
);
}
function hasKnownEvidence(
sourceCode: SourceCode,
expression: ESTree.Expression,
visitedVariables = new Set<Variable>(),
): boolean {
if (isKnownEvidenceExpression(expression)) return true;
const unwrapped = unwrapExpression(expression);
if (unwrapped.type !== "Identifier") return false;
const variable = resolveVariable(sourceCode, unwrapped);
if (variable === null || visitedVariables.has(variable)) return false;
const declarator = variableDeclarator(variable);
if (
declarator === null ||
declarator.init === null ||
!isStableConstVariable(variable, declarator)
) {
return false;
}
visitedVariables.add(variable);
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
}
function isFunctionExpression(node: ESTree.Node): node is FunctionExpression {
return (
node.type === "ArrowFunctionExpression" ||
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "TSDeclareFunction" ||
node.type === "TSEmptyBodyFunctionExpression"
);
}
function localFunctionForCall(
sourceCode: SourceCode,
callee: ESTree.Expression,
): FunctionExpression | null {
const unwrapped = unwrapExpression(callee);
if (isFunctionExpression(unwrapped)) return unwrapped;
if (unwrapped.type !== "Identifier") return null;
const variable = resolveVariable(sourceCode, unwrapped);
if (variable === null || variable.defs.length !== 1) return null;
const [definition] = variable.defs;
if (definition === undefined) return null;
if (definition.type === "FunctionName" && isFunctionExpression(definition.node)) {
return definition.node;
}
if (definition.type !== "Variable" || definition.node.type !== "VariableDeclarator") {
return null;
}
const initializer = definition.node.init;
if (initializer === null) return null;
const unwrappedInitializer = unwrapExpression(initializer);
return isFunctionExpression(unwrappedInitializer) ? unwrappedInitializer : null;
}
function variableTypeAnnotation(
sourceCode: SourceCode,
variable: Variable,
): ESTree.TSTypeAnnotation | null {
if (variable.defs.length !== 1) return null;
const [definition] = variable.defs;
if (definition === undefined) return null;
if (
definition.type === "Variable" &&
definition.node.type === "VariableDeclarator" &&
definition.node.id.type === "Identifier"
) {
return definition.node.id.typeAnnotation ?? null;
}
if (definition.type !== "Parameter" || !isFunctionExpression(definition.node)) {
return null;
}
const parameter = definition.node.params.find(
(candidate) =>
functionParameterBindingName(candidate, sourceCode) === variable.name,
);
return parameter === undefined ? null : (functionParameterTypeAnnotation(parameter) ?? null);
}
function hasInformativeType(
type: ESTree.TSType,
environment: TypeEnvironment,
): boolean {
return classifyUnsafeDictionaryValue(type, environment) === null;
}
function hasKnownCallArgumentEvidence(
sourceCode: SourceCode,
expression: ESTree.Expression,
environment: TypeEnvironment,
visitedVariables = new Set<Variable>(),
): boolean {
if (expression.type === "ParenthesizedExpression" || expression.type === "TSNonNullExpression") {
return hasKnownCallArgumentEvidence(
sourceCode,
expression.expression,
environment,
visitedVariables,
);
}
if (expression.type === "TSAsExpression" || expression.type === "TSTypeAssertion") {
return hasInformativeType(expression.typeAnnotation, environment);
}
if (expression.type === "TSSatisfiesExpression") {
return hasKnownCallArgumentEvidence(
sourceCode,
expression.expression,
environment,
visitedVariables,
);
}
if (expression.type === "CallExpression") {
const owner = localFunctionForCall(sourceCode, expression.callee);
const returnType = owner?.returnType?.typeAnnotation;
return returnType !== undefined && hasInformativeType(returnType, environment);
}
if (expression.type !== "Identifier") return isKnownEvidenceExpression(expression);
const variable = resolveVariable(sourceCode, expression);
if (variable === null || visitedVariables.has(variable)) return false;
const annotation = variableTypeAnnotation(sourceCode, variable);
if (annotation !== null) {
return hasInformativeType(annotation.typeAnnotation, environment);
}
const declarator = variableDeclarator(variable);
if (
declarator === null ||
declarator.init === null ||
!isStableConstVariable(variable, declarator)
) {
return false;
}
visitedVariables.add(variable);
return hasKnownCallArgumentEvidence(
sourceCode,
declarator.init,
environment,
visitedVariables,
);
}
function typePredicateSubjectIndex(
sourceCode: SourceCode,
owner: FunctionExpression,
): number | null {
const predicate = owner.returnType?.typeAnnotation;
if (predicate?.type !== "TSTypePredicate" || predicate.parameterName.type !== "Identifier") {
return null;
}
const predicateParameterName = predicate.parameterName.name;
const index = owner.params.findIndex(
(parameter) =>
functionParameterBindingName(parameter, sourceCode) === predicateParameterName,
);
return index === -1 ? null : index;
}
function annotationTarget(
annotation: ESTree.TSTypeAnnotation | null | undefined,
environment: TypeEnvironment,
): WideningTarget | null {
return annotation === null || annotation === undefined
? null
: classifyWideningTarget(annotation.typeAnnotation, environment);
}
function enclosingFunction(node: ESTree.Node): FunctionExpression | null {
let current: ESTree.Node | null = node.parent;
while (current !== null && current.type !== "Program") {
if (
current.type === "ArrowFunctionExpression" ||
current.type === "FunctionDeclaration" ||
current.type === "FunctionExpression"
) {
return current;
}
current = current.parent;
}
return null;
}
function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string {
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
if (key.type === "Literal") return String(key.value);
return sourceCode.getText(key);
}
function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string {
if (owner === null) return "anonymous function";
if (owner.id !== null) return owner.id.name;
const parent = owner.parent;
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier")
return parent.id.name;
if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
return "anonymous function";
}
function isEmptyObjectExpression(expression: ESTree.Expression): boolean {
const unwrapped = unwrapExpression(expression);
return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0;
}
function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean {
return destination.kind === "open dictionary" || destination.kind === "generic container";
}
function hasParentAssertion(node: ESTree.Node): boolean {
return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion";
}
/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */
export const noKnownValueWideningRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.",
},
messages: {
widening:
"The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.",
},
},
createOnce(context) {
let environment: TypeEnvironment | null = null;
const reportFlow = (
expression: ESTree.Expression,
destination: WideningTarget | null,
subject: string,
) => {
if (destination === null) return;
if (
isDictionaryAccumulatorTarget(destination) &&
isEmptyObjectExpression(expression)
) {
return;
}
if (!hasKnownEvidence(context.sourceCode, expression)) return;
context.report({
node: expression,
messageId: "widening",
data: { subject, target: destination.kind },
});
};
const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) =>
environment === null ? null : annotationTarget(annotation, environment);
return {
Program(node) {
environment = createTypeEnvironment(
node,
context.sourceCode.visitorKeys,
);
},
VariableDeclarator(node) {
if (node.init === null || node.id.type !== "Identifier") return;
reportFlow(
node.init,
targetFromAnnotation(node.id.typeAnnotation),
`binding \`${node.id.name}\``,
);
},
PropertyDefinition(node) {
if (node.value === null) return;
reportFlow(
node.value,
targetFromAnnotation(node.typeAnnotation),
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
);
},
AccessorProperty(node) {
if (node.value === null) return;
reportFlow(
node.value,
targetFromAnnotation(node.typeAnnotation),
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
);
},
AssignmentExpression(node) {
if (node.operator !== "=" || node.left.type !== "Identifier") return;
const variable = resolveVariable(context.sourceCode, node.left);
if (variable === null) return;
const declarator = variableDeclarator(variable);
if (declarator === null || declarator.id.type !== "Identifier") return;
reportFlow(
node.right,
targetFromAnnotation(declarator.id.typeAnnotation),
`binding \`${declarator.id.name}\``,
);
},
CallExpression(node) {
if (environment === null) return;
const owner = localFunctionForCall(context.sourceCode, node.callee);
if (owner === null) return;
const parameterIndex = typePredicateSubjectIndex(context.sourceCode, owner);
if (parameterIndex === null) return;
const parameter = owner.params[parameterIndex];
const argument = node.arguments[parameterIndex];
if (parameter === undefined || argument === undefined || argument.type === "SpreadElement") {
return;
}
const parameterAnnotation = functionParameterTypeAnnotation(parameter);
if (
parameterAnnotation === null ||
parameterAnnotation === undefined ||
!containsUnknownType(parameterAnnotation.typeAnnotation)
) {
return;
}
if (
!hasKnownCallArgumentEvidence(
context.sourceCode,
argument,
environment,
)
) {
return;
}
context.report({
node: argument,
messageId: "widening",
data: {
subject: `argument for parameter \`${functionParameterBindingName(parameter, context.sourceCode)}\` of \`${functionName(context.sourceCode, owner)}\``,
target: "unknown",
},
});
},
ReturnStatement(node) {
if (node.argument === null) return;
const owner = enclosingFunction(node);
reportFlow(
node.argument,
targetFromAnnotation(owner?.returnType),
`return value of \`${functionName(context.sourceCode, owner)}\``,
);
},
ArrowFunctionExpression(node) {
if (node.body.type === "BlockStatement") return;
reportFlow(
node.body,
targetFromAnnotation(node.returnType),
`return value of \`${functionName(context.sourceCode, node)}\``,
);
},
TSAsExpression(node) {
if (environment === null || hasParentAssertion(node)) return;
reportFlow(
node.expression,
classifyWideningTarget(node.typeAnnotation, environment),
"assertion",
);
},
TSTypeAssertion(node) {
if (environment === null || hasParentAssertion(node)) return;
reportFlow(
node.expression,
classifyWideningTarget(node.typeAnnotation, environment),
"assertion",
);
},
};
},
});
@@ -0,0 +1,80 @@
import { defineRule } from "@oxlint/plugins";
import { resolveVariable } from "../shared/scope.ts";
import type { ESTree, SourceCode } from "@oxlint/plugins";
const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]);
function importedName(node: ESTree.Node): string | null {
if (node.type !== "ImportSpecifier") return null;
return node.imported.type === "Identifier" ? node.imported.name : node.imported.value;
}
function isTestFrameworkObject(
sourceCode: SourceCode,
expression: ESTree.Expression,
): expression is ESTree.IdentifierReference {
if (expression.type !== "Identifier") return false;
if (
(expression.name === "vi" || expression.name === "jest") &&
sourceCode.isGlobalReference(expression)
) {
return true;
}
const variable = resolveVariable(sourceCode, expression);
if (variable === null || variable.defs.length === 0) {
return expression.name === "vi" || expression.name === "jest";
}
return variable.defs.some((definition) => {
if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") {
return false;
}
const source = definition.parent.source.value;
const name = importedName(definition.node);
return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest");
});
}
function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean {
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
if (!isTestFrameworkObject(sourceCode, callee.object)) return false;
const property = callee.property;
const method = callee.computed
? property.type === "Literal" &&
(property.value === "doMock" ||
property.value === "mock" ||
property.value === "unstable_mockModule")
? property.value
: null
: property.type === "Identifier"
? property.name
: null;
return method !== null && moduleMockMethods.has(method);
}
/** Ban test framework module mocking in favor of real dependency seams. */
export const noModuleMockingRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.",
},
messages: {
moduleMock:
"Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.",
},
},
createOnce(context) {
return {
CallExpression(node) {
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
if (moduleMockCall(context.sourceCode, node.callee)) {
context.report({ node, messageId: "moduleMock" });
}
},
};
},
});
@@ -0,0 +1,83 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
import {
functionParameterBindingName,
functionParameterTypeAnnotation,
} from "../shared/function-parameters.ts";
import {
createTypeAliasEnvironment,
resolvedTypeMatches,
type TypeAliasEnvironment,
} from "../shared/type-alias-resolution.ts";
type ParameterOwner =
| ESTree.ArrowFunctionExpression
| ESTree.Function
| ESTree.TSCallSignatureDeclaration
| ESTree.TSConstructSignatureDeclaration
| ESTree.TSConstructorType
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
/** Ban the broad object type on function inputs, including local aliases to object. */
export const noObjectParametersRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.",
},
messages: {
objectParameter:
"Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.",
},
},
createOnce(context) {
let environment: TypeAliasEnvironment | null = null;
const resolvesToObject = (type: ESTree.TSType): boolean =>
environment !== null &&
resolvedTypeMatches(type, environment, (resolved, matches) => {
if (resolved.type === "TSObjectKeyword") return true;
if (resolved.type === "TSParenthesizedType") {
return matches(resolved.typeAnnotation);
}
return (
resolved.type === "TSUnionType" && resolved.types.some(matches)
);
});
const checkParameters = (node: ParameterOwner) => {
for (const parameter of node.params) {
const annotation = functionParameterTypeAnnotation(parameter);
if (annotation === null || annotation === undefined) continue;
if (!resolvesToObject(annotation.typeAnnotation)) continue;
context.report({
node: annotation.typeAnnotation,
messageId: "objectParameter",
data: { parameter: functionParameterBindingName(parameter, context.sourceCode) },
});
}
};
return {
Program(node) {
environment = createTypeAliasEnvironment(
node,
context.sourceCode.visitorKeys,
);
},
ArrowFunctionExpression: checkParameters,
FunctionDeclaration: checkParameters,
FunctionExpression: checkParameters,
TSCallSignatureDeclaration: checkParameters,
TSConstructSignatureDeclaration: checkParameters,
TSConstructorType: checkParameters,
TSDeclareFunction: checkParameters,
TSEmptyBodyFunctionExpression: checkParameters,
TSFunctionType: checkParameters,
TSMethodSignature: checkParameters,
};
},
});
@@ -0,0 +1,109 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree, SourceCode, Variable } from "@oxlint/plugins";
import {
arrayMethodTarget,
isKnownArrayExpression,
resolveArrayBinding,
unwrapArrayExpression,
} from "../shared/array-method.ts";
function enclosingReducer(node: ESTree.Node) {
let parent = node.parent;
while (parent !== null) {
if (parent.type === "FunctionDeclaration") return null;
if (parent.type === "ArrowFunctionExpression" || parent.type === "FunctionExpression") {
const callback = parent;
let owner: ESTree.Node | null = callback.parent;
while (owner !== null && unwrapArrayExpression(owner) === callback) owner = owner.parent;
if (owner?.type !== "CallExpression") return null;
const method = arrayMethodTarget(owner.callee);
const firstArgument = owner.arguments[0];
if (
method === null || (method.name !== "reduce" && method.name !== "reduceRight") ||
owner.arguments.length > 2 || firstArgument === undefined ||
unwrapArrayExpression(firstArgument) !== callback
) return null;
const firstParameter = callback.params[0];
const accumulator = firstParameter?.type === "AssignmentPattern" ? firstParameter.left : firstParameter;
if (accumulator?.type !== "Identifier") return null;
return { callback, accumulator, initialValue: owner.arguments[1] };
}
parent = parent.parent;
}
return null;
}
function referencesAccumulator(
sourceCode: SourceCode,
node: ESTree.Node,
accumulator: Variable,
visited = new Set<Variable>(),
): boolean {
const variable = resolveArrayBinding(sourceCode, node);
if (variable === null || visited.has(variable)) return false;
if (variable === accumulator) return true;
visited.add(variable);
if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false;
for (const definition of variable.defs) {
if (
definition.type === "Variable" && definition.node.type === "VariableDeclarator" &&
definition.node.id.type === "Identifier" && definition.node.init !== null &&
definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const"
) {
return referencesAccumulator(sourceCode, definition.node.init, accumulator, visited);
}
}
return false;
}
function isGlobalCopyOwner(sourceCode: SourceCode, node: ESTree.Node, name: string): boolean {
node = unwrapArrayExpression(node);
if (node.type !== "Identifier" || node.name !== name) return false;
const variable = resolveArrayBinding(sourceCode, node);
return variable === null || variable.defs.length === 0;
}
/** Reject non-spread copies of reducer accumulators; pair with oxc/no-accumulating-spread. */
export const noReduceAccumulatorCopyRule = defineRule({
meta: {
type: "problem",
docs: { description: "Disallow copying growing reducer accumulators with Object.assign, Array.from, or array copy methods." },
messages: {
accumulatorCopy: "Do not copy the reducer accumulator on every iteration; growing copies can cause quadratic work. Mutate a fresh, locally owned accumulator and return it, or use an iterator pipeline/flatMap.",
},
},
createOnce(context) {
return {
CallExpression(node) {
const method = arrayMethodTarget(node.callee);
if (method === null) return;
const reducer = enclosingReducer(node);
if (reducer === null) return;
const accumulator = context.sourceCode.getDeclaredVariables(reducer.callback).find(variable =>
variable.identifiers.some(identifier => identifier.start === reducer.accumulator.start),
);
if (accumulator === undefined) return;
const isAccumulator = (expression: ESTree.Node) =>
referencesAccumulator(context.sourceCode, expression, accumulator);
let copiesAccumulator = false;
if (method.name === "assign" && isGlobalCopyOwner(context.sourceCode, method.object, "Object")) {
const target = node.arguments[0];
copiesAccumulator = (
target !== undefined && unwrapArrayExpression(target).type === "ObjectExpression" &&
node.arguments.slice(1).some(isAccumulator)
);
} else if (method.name === "from" && isGlobalCopyOwner(context.sourceCode, method.object, "Array")) {
const source = node.arguments[0];
copiesAccumulator = source !== undefined && isAccumulator(source);
} else if (["concat", "slice", "toSpliced", "toSorted", "toReversed", "with"].includes(method.name)) {
const initialValue = reducer.initialValue;
const arrayAccumulator = initialValue !== undefined &&
isKnownArrayExpression(context.sourceCode, initialValue);
copiesAccumulator = arrayAccumulator && isAccumulator(method.object);
}
if (copiesAccumulator) context.report({ node, messageId: "accumulatorCopy" });
},
};
},
});
@@ -0,0 +1,28 @@
import { defineRule } from "@oxlint/plugins";
import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts";
/** Ban Reflect.apply, which bypasses ordinary typed function calls. */
export const noReflectApplyRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.",
},
messages: {
reflectApply:
"Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.",
},
},
createOnce(context) {
return {
CallExpression(node) {
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) {
context.report({ node, messageId: "reflectApply" });
}
},
};
},
});
@@ -0,0 +1,28 @@
import { defineRule } from "@oxlint/plugins";
import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts";
/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */
export const noReflectGetRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.",
},
messages: {
reflectGet:
"Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.",
},
},
createOnce(context) {
return {
CallExpression(node) {
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) {
context.report({ node, messageId: "reflectGet" });
}
},
};
},
});
@@ -0,0 +1,77 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function;
function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction {
return (
node.type === "ArrowFunctionExpression" ||
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression"
);
}
function isInsideTypeGuard(node: ESTree.Node): boolean {
let current: ESTree.Node | null = node.parent;
while (current !== null && current.type !== "Program") {
if (isRuntimeFunction(current)) {
return current.returnType?.typeAnnotation.type === "TSTypePredicate";
}
current = current.parent;
}
return false;
}
/** Return whether typeof safely probes for the existence of a possibly absent binding. */
function isExistenceProbe(node: ESTree.UnaryExpression): boolean {
const parent = node.parent;
if (parent.type !== "BinaryExpression") return false;
if (!["===", "!==", "==", "!="].includes(parent.operator)) return false;
const other = parent.left === node ? parent.right : parent.left;
return other.type === "Literal" && other.value === "undefined";
}
/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */
export const noRuntimeTypeofRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.",
},
messages: {
runtimeTypeof:
"A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.",
},
schema: [
{
type: "object",
properties: {
allowInTypeGuards: { type: "boolean" },
},
additionalProperties: false,
},
],
defaultOptions: [{ allowInTypeGuards: false }],
},
createOnce(context) {
return {
UnaryExpression(node) {
const option = context.options?.[0];
const allowInTypeGuards =
typeof option === "object" &&
option !== null &&
!Array.isArray(option) &&
option.allowInTypeGuards === true;
if (
node.operator === "typeof" &&
!isExistenceProbe(node) &&
(!allowInTypeGuards || !isInsideTypeGuard(node))
) {
context.report({ node, messageId: "runtimeTypeof" });
}
},
};
},
});
@@ -0,0 +1,46 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
const FORBIDDEN_SYMBOL_NAME = "shape";
function containsForbiddenSymbolName(name: string): boolean {
return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME);
}
/** Return whether an identifier names a statically accessed member owned by another value. */
function isBorrowedMemberName(node: ESTree.Node): boolean {
const parent = node.parent;
if (parent === null || parent.type !== "MemberExpression") return false;
return parent.property === node && parent.computed === false;
}
/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */
export const noForbiddenTermInSymbolNamesRule = defineRule({
meta: {
type: "problem",
docs: {
description:
'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.',
},
messages: {
forbiddenSymbolName:
'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.',
},
},
createOnce(context) {
const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => {
if (!containsForbiddenSymbolName(node.name) || isBorrowedMemberName(node)) return;
context.report({
node,
messageId: "forbiddenSymbolName",
data: { name: node.name },
});
};
return {
Identifier: reportForbiddenSymbolName,
PrivateIdentifier: reportForbiddenSymbolName,
JSXIdentifier: reportForbiddenSymbolName,
};
},
});
@@ -0,0 +1,69 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
import {
containsUnknownType,
functionParameterBindingName,
functionParameterTypeAnnotation,
} from "../shared/function-parameters.ts";
type ParameterOwner =
| ESTree.ArrowFunctionExpression
| ESTree.Function
| ESTree.TSCallSignatureDeclaration
| ESTree.TSConstructSignatureDeclaration
| ESTree.TSConstructorType
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
function isTypePredicateSubject(owner: ParameterOwner, parameterName: string): boolean {
const predicate = owner.returnType?.typeAnnotation;
return (
predicate?.type === "TSTypePredicate" &&
predicate.parameterName.type === "Identifier" &&
predicate.parameterName.name === parameterName
);
}
/** Disallow unknown inputs except explicitly named error-cause enrichment. */
export const noUnknownParametersRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow explicitly unknown function parameters except `cause` and type-predicate subjects; decode unknown input at its I/O boundary instead.",
},
messages: {
unknownParameter:
"Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.",
},
},
createOnce(context) {
const checkParameters = (node: ParameterOwner) => {
for (const parameter of node.params) {
const annotation = functionParameterTypeAnnotation(parameter);
if (annotation === null || annotation === undefined) continue;
if (!containsUnknownType(annotation.typeAnnotation)) continue;
const name = functionParameterBindingName(parameter, context.sourceCode);
if (name === "cause" || isTypePredicateSubject(node, name)) continue;
context.report({
node: annotation.typeAnnotation,
messageId: "unknownParameter",
data: { parameter: name },
});
}
};
return {
ArrowFunctionExpression: checkParameters,
FunctionDeclaration: checkParameters,
FunctionExpression: checkParameters,
TSCallSignatureDeclaration: checkParameters,
TSConstructSignatureDeclaration: checkParameters,
TSConstructorType: checkParameters,
TSDeclareFunction: checkParameters,
TSEmptyBodyFunctionExpression: checkParameters,
TSFunctionType: checkParameters,
TSMethodSignature: checkParameters,
};
},
});
@@ -0,0 +1,82 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
import {
createTypeAliasEnvironment,
resolvedTypeMatches,
type TypeAliasEnvironment,
} from "../shared/type-alias-resolution.ts";
type FunctionWithReturnType =
| ESTree.ArrowFunctionExpression
| ESTree.Function
| ESTree.TSCallSignatureDeclaration
| ESTree.TSConstructSignatureDeclaration
| ESTree.TSConstructorType
| ESTree.TSFunctionType
| ESTree.TSMethodSignature;
/** Ban function contracts that return unknown instead of a parsed domain type. */
export const noUnknownReturnsRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow functions whose explicit return contract is unknown or Promise<unknown>.",
},
messages: {
unknownReturn:
"This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.",
},
},
createOnce(context) {
let environment: TypeAliasEnvironment | null = null;
const resolvesToUnknown = (type: ESTree.TSType): boolean =>
environment !== null &&
resolvedTypeMatches(type, environment, (resolved, matches) => {
if (resolved.type === "TSUnknownKeyword") return true;
if (resolved.type === "TSParenthesizedType") {
return matches(resolved.typeAnnotation);
}
if (resolved.type === "TSUnionType") return resolved.types.some(matches);
if (
resolved.type !== "TSTypeReference" ||
resolved.typeName.type !== "Identifier" ||
(resolved.typeName.name !== "Promise" &&
resolved.typeName.name !== "PromiseLike")
) {
return false;
}
const value = resolved.typeArguments?.params[0];
return value !== undefined && matches(value);
});
const checkReturnType = (node: FunctionWithReturnType) => {
const annotation = node.returnType;
if (annotation === null || annotation === undefined) return;
if (!resolvesToUnknown(annotation.typeAnnotation)) return;
context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" });
};
return {
Program(node) {
environment = createTypeAliasEnvironment(
node,
context.sourceCode.visitorKeys,
);
},
ArrowFunctionExpression: checkReturnType,
FunctionDeclaration: checkReturnType,
FunctionExpression: checkReturnType,
TSCallSignatureDeclaration: checkReturnType,
TSConstructSignatureDeclaration: checkReturnType,
TSConstructorType: checkReturnType,
TSDeclareFunction: checkReturnType,
TSEmptyBodyFunctionExpression: checkReturnType,
TSFunctionType: checkReturnType,
TSMethodSignature: checkReturnType,
};
},
});
@@ -0,0 +1,54 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree } from "@oxlint/plugins";
import {
createTypeAliasEnvironment,
resolvedTypeMatches,
type TypeAliasEnvironment,
} from "../shared/type-alias-resolution.ts";
/** Ban named aliases that merely conceal TypeScript's unknown top type. */
export const noUnknownTypeAliasesRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.",
},
messages: {
unknownAlias:
"Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.",
},
},
createOnce(context) {
let environment: TypeAliasEnvironment | null = null;
const resolvesToUnknown = (type: ESTree.TSType): boolean =>
environment !== null &&
resolvedTypeMatches(type, environment, (resolved, matches) => {
if (resolved.type === "TSUnknownKeyword") return true;
if (resolved.type === "TSParenthesizedType") {
return matches(resolved.typeAnnotation);
}
return resolved.type === "TSUnionType" && resolved.types.some(matches);
});
return {
Program(node) {
environment = createTypeAliasEnvironment(
node,
context.sourceCode.visitorKeys,
);
},
TSTypeAliasDeclaration(node) {
if (!resolvesToUnknown(node.typeAnnotation)) return;
context.report({
node: node.id,
messageId: "unknownAlias",
data: { alias: node.id.name },
});
},
};
},
});
@@ -0,0 +1,154 @@
import { defineRule } from "@oxlint/plugins";
import {
classifyUnsafeDictionary,
classifyUnsafeDictionaryValue,
createTypeEnvironment,
type TypeEnvironment,
} from "../shared/dictionary-types.ts";
import { visibleTypeAlias } from "../shared/type-alias-resolution.ts";
import type { ESTree } from "@oxlint/plugins";
const typeNodeKinds: ReadonlySet<string> = new Set([
"JSDocNonNullableType",
"JSDocNullableType",
"JSDocUnknownType",
"TSAnyKeyword",
"TSArrayType",
"TSBigIntKeyword",
"TSBooleanKeyword",
"TSConditionalType",
"TSConstructorType",
"TSFunctionType",
"TSImportType",
"TSIndexedAccessType",
"TSInferType",
"TSIntersectionType",
"TSIntrinsicKeyword",
"TSLiteralType",
"TSMappedType",
"TSNamedTupleMember",
"TSNeverKeyword",
"TSNullKeyword",
"TSNumberKeyword",
"TSObjectKeyword",
"TSParenthesizedType",
"TSStringKeyword",
"TSSymbolKeyword",
"TSTemplateLiteralType",
"TSThisType",
"TSTupleType",
"TSTypeLiteral",
"TSTypeOperator",
"TSTypePredicate",
"TSTypeQuery",
"TSTypeReference",
"TSUndefinedKeyword",
"TSUnionType",
"TSUnknownKeyword",
"TSVoidKeyword",
]);
function isTypeNode(node: ESTree.Node): node is ESTree.TSType {
return typeNodeKinds.has(node.type);
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean {
let current: ESTree.Node | null = node.parent;
while (current !== null && current.type !== "Program") {
if (current.type === "TSTypeAliasDeclaration") return true;
current = current.parent;
}
return false;
}
function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean {
if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false;
const name = typeReferenceName(node);
return (
name !== null &&
visibleTypeAlias(name, node, environment.typeAliases) !== null &&
!isInsideTypeAliasDeclaration(node)
);
}
function isInsideTypeParameterConstraint(node: ESTree.TSType): boolean {
let child: ESTree.Node = node;
let parent: ESTree.Node | null = child.parent;
while (parent !== null && parent.type !== "Program") {
if (parent.type === "TSTypeParameter" && parent.constraint === child) return true;
child = parent;
parent = child.parent;
}
return false;
}
function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean {
if (isInsideTypeParameterConstraint(node)) return false;
if (isPlainAliasConsumerUse(node, environment)) return false;
if (classifyUnsafeDictionary(node, environment) === null) return false;
let current: ESTree.Node | null = node.parent;
while (current !== null && current.type !== "Program") {
if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null)
return false;
current = current.parent;
}
return true;
}
/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */
export const noUnsafeDictionaryTypeRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.",
},
messages: {
unsafeDictionary:
"This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.",
},
},
createOnce(context) {
let environment: TypeEnvironment | null = null;
const report = (node: ESTree.Node, value: string) => {
context.report({ node, messageId: "unsafeDictionary", data: { value } });
};
const reportIfUnsafe = (node: ESTree.TSType) => {
if (environment === null || !shouldReportType(node, environment)) return;
const unsafe = classifyUnsafeDictionary(node, environment);
if (unsafe === null) return;
report(node, unsafe.unsafeValue);
};
return {
Program(node) {
environment = createTypeEnvironment(
node,
context.sourceCode.visitorKeys,
);
},
TSTypeReference: reportIfUnsafe,
TSTypeLiteral: reportIfUnsafe,
TSMappedType: reportIfUnsafe,
TSIndexSignature(node) {
if (
environment === null ||
node.typeAnnotation === null ||
node.parent.type === "TSTypeLiteral"
)
return;
const unsafe = classifyUnsafeDictionaryValue(
node.typeAnnotation.typeAnnotation,
environment,
);
if (unsafe !== null) report(node, unsafe.unsafeValue);
},
};
},
});
@@ -0,0 +1,366 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree, Variable } from "@oxlint/plugins";
type BroadTypeKind = "top" | "object" | "record";
type KnownValueEvidence = {
readonly type: ESTree.TSType | null;
};
const functionBoundaryTypes = new Set([
"ArrowFunctionExpression",
"FunctionDeclaration",
"FunctionExpression",
"TSDeclareFunction",
"TSEmptyBodyFunctionExpression",
]);
function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression {
let current = expression;
while (current.type === "ParenthesizedExpression") current = current.expression;
return current;
}
function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType {
let current = type;
while (current.type === "TSParenthesizedType") current = current.typeAnnotation;
return current;
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function isUnknownOrAnyType(type: ESTree.TSType): boolean {
const unwrapped = unwrapTypeParentheses(type);
return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword";
}
function isBroadRecordKeyType(type: ESTree.TSType): boolean {
const unwrapped = unwrapTypeParentheses(type);
if (
unwrapped.type === "TSStringKeyword" ||
unwrapped.type === "TSNumberKeyword" ||
unwrapped.type === "TSSymbolKeyword"
) {
return true;
}
if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType);
return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey";
}
function isBroadRecordType(type: ESTree.TSType): boolean {
const unwrapped = unwrapTypeParentheses(type);
if (unwrapped.type === "TSTypeReference") {
if (typeReferenceName(unwrapped) === "Readonly") {
const [inner] = unwrapped.typeArguments?.params ?? [];
return inner !== undefined && isBroadRecordType(inner);
}
if (typeReferenceName(unwrapped) !== "Record") return false;
const parameters = unwrapped.typeArguments?.params ?? [];
return (
parameters.length === 2 &&
parameters[0] !== undefined &&
parameters[1] !== undefined &&
isBroadRecordKeyType(parameters[0]) &&
isUnknownOrAnyType(parameters[1])
);
}
if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false;
const [member] = unwrapped.members;
const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : [];
return (
member?.type === "TSIndexSignature" &&
member.parameters.length === 1 &&
parameter !== undefined &&
isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) &&
isUnknownOrAnyType(member.typeAnnotation.typeAnnotation)
);
}
function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null {
const unwrapped = unwrapTypeParentheses(type);
if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top";
if (unwrapped.type === "TSObjectKeyword") return "object";
return isBroadRecordType(unwrapped) ? "record" : null;
}
function assertedExpression(
node: ESTree.TSAsExpression | ESTree.TSTypeAssertion,
): ESTree.Expression {
return unwrapExpressionParentheses(node.expression);
}
function assertionFromExpression(
expression: ESTree.Expression,
): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null {
const unwrapped = unwrapExpressionParentheses(expression);
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
? unwrapped
: null;
}
function normalizedTypeText(sourceText: string, type: ESTree.TSType): string {
return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, "");
}
function typesHaveSameSyntax(
sourceText: string,
left: ESTree.TSType | null,
right: ESTree.TSType,
): boolean {
return (
left !== null &&
normalizedTypeText(sourceText, unwrapTypeParentheses(left)) ===
normalizedTypeText(sourceText, unwrapTypeParentheses(right))
);
}
function isDefinitelyObjectType(type: ESTree.TSType): boolean {
const unwrapped = unwrapTypeParentheses(type);
switch (unwrapped.type) {
case "TSArrayType":
case "TSConstructorType":
case "TSFunctionType":
case "TSMappedType":
case "TSObjectKeyword":
case "TSTupleType":
return true;
case "TSTypeLiteral":
return unwrapped.members.length > 0;
case "TSIntersectionType":
return unwrapped.types.every(isDefinitelyObjectType);
case "TSTypeOperator":
return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation);
default:
return false;
}
}
function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean {
const unwrapped = unwrapTypeParentheses(type);
if (unwrapped.type === "TSTypeLiteral") {
return unwrapped.members.some((member) => member.type !== "TSIndexSignature");
}
if (unwrapped.type !== "TSTypeReference") return false;
if (typeReferenceName(unwrapped) === "Readonly") {
const [inner] = unwrapped.typeArguments?.params ?? [];
return inner !== undefined && isDefinitelyNarrowerRecordType(inner);
}
if (typeReferenceName(unwrapped) !== "Record") return false;
const parameters = unwrapped.typeArguments?.params ?? [];
return (
parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1])
);
}
function functionBoundary(node: ESTree.Node): ESTree.Node | null {
let current = node.parent;
while (current !== null && current.type !== "Program") {
if (functionBoundaryTypes.has(current.type)) return current;
current = current.parent;
}
return null;
}
function resolvedVariableForIdentifier(
scopes: readonly {
readonly references: readonly {
readonly identifier: ESTree.Node;
readonly resolved: Variable | null;
}[];
}[],
identifier: ESTree.IdentifierReference,
): Variable | null {
for (const scope of scopes) {
const reference = scope.references.find(
(candidate) =>
candidate.identifier.start === identifier.start &&
candidate.identifier.end === identifier.end,
);
if (reference !== undefined) return reference.resolved;
}
return null;
}
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
for (const definition of variable.defs) {
if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") {
return definition.node;
}
}
return null;
}
function knownValueEvidence(
expression: ESTree.Expression,
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
boundary: ESTree.Node | null,
visitedVariables: ReadonlySet<Variable>,
): KnownValueEvidence | null {
const unwrapped = unwrapExpressionParentheses(expression);
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null;
return { type: unwrapped.typeAnnotation };
}
if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") {
return { type: null };
}
if (
unwrapped.type === "ArrayExpression" ||
unwrapped.type === "ArrowFunctionExpression" ||
unwrapped.type === "ClassExpression" ||
unwrapped.type === "FunctionExpression" ||
unwrapped.type === "NewExpression" ||
unwrapped.type === "ObjectExpression"
) {
return { type: null };
}
if (unwrapped.type !== "Identifier") return null;
const variable = resolvedVariableForIdentifier(scopes, unwrapped);
if (variable === null || visitedVariables.has(variable)) return null;
const annotatedIdentifier = variable.identifiers.find(
(identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined,
);
const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation;
if (annotation !== undefined && annotatedIdentifier !== undefined) {
if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) {
return null;
}
return { type: annotation };
}
const declarator = variableDeclarator(variable);
if (
declarator === null ||
declarator.parent.type !== "VariableDeclaration" ||
declarator.parent.kind !== "const" ||
declarator.init === null ||
variable.references.some((reference) => reference.isWrite() && !reference.init) ||
functionBoundary(declarator) !== boundary
) {
return null;
}
return knownValueEvidence(
declarator.init,
scopes,
boundary,
new Set([...visitedVariables, variable]),
);
}
function widenedBinding(
variable: Variable,
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
): {
readonly broadKind: BroadTypeKind;
readonly evidence: KnownValueEvidence;
readonly declaredAt: number;
readonly boundary: ESTree.Node | null;
} | null {
const declarator = variableDeclarator(variable);
if (
declarator === null ||
declarator.parent.type !== "VariableDeclaration" ||
declarator.parent.kind !== "const" ||
declarator.id.type !== "Identifier" ||
declarator.init === null ||
variable.references.some((reference) => reference.isWrite() && !reference.init)
) {
return null;
}
const boundary = functionBoundary(declarator);
const declaredType = declarator.id.typeAnnotation?.typeAnnotation;
const initializerAssertion = assertionFromExpression(declarator.init);
const initializerBroadKind =
initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation);
const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType);
const broadKind = declaredBroadKind ?? initializerBroadKind;
if (broadKind === null) return null;
const originalExpression =
initializerAssertion !== null && initializerBroadKind !== null
? assertedExpression(initializerAssertion)
: declarator.init;
const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable]));
return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary };
}
function assertionIsNarrower(
sourceText: string,
broadKind: BroadTypeKind,
evidence: KnownValueEvidence,
assertedType: ESTree.TSType,
): boolean {
if (broadTypeKind(assertedType) !== null) return false;
if (broadKind === "top") return true;
if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true;
if (broadKind === "object") return isDefinitelyObjectType(assertedType);
return isDefinitelyNarrowerRecordType(assertedType);
}
/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */
export const noWidenThenAssertRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.",
},
messages: {
widenThenAssert:
'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.',
},
},
createOnce(context) {
let scopes: Parameters<typeof resolvedVariableForIdentifier>[0] = [];
const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => {
const expression = assertedExpression(node);
if (expression.type !== "Identifier") return;
const variable = resolvedVariableForIdentifier(scopes, expression);
if (variable === null) return;
const widened = widenedBinding(variable, scopes);
if (
widened === null ||
node.start <= widened.declaredAt ||
functionBoundary(node) !== widened.boundary ||
!assertionIsNarrower(
context.sourceCode.text,
widened.broadKind,
widened.evidence,
node.typeAnnotation,
)
) {
return;
}
context.report({
node,
messageId: "widenThenAssert",
data: { name: expression.name },
});
};
return {
Program() {
scopes = context.sourceCode.scopeManager.scopes;
},
TSAsExpression: checkAssertion,
TSTypeAssertion: checkAssertion,
};
},
});
@@ -0,0 +1,80 @@
import type { CreateRule, ESTree, SourceCode } from "@oxlint/plugins";
import createPaddingLineRule from "../vendor/eslint-stylistic/padding-line-between-statements.ts";
import type { NodeTestObject } from "../vendor/eslint-stylistic/padding-line-between-statements.ts";
/** A `const`/`let`/`var`/`using` declaration, optionally exported, written on one line. */
function isSinglelineBinding(node: ESTree.Node): boolean {
const declaration = node.type === "ExportNamedDeclaration" ? node.declaration : node;
return declaration?.type === "VariableDeclaration" && node.loc.start.line === node.loc.end.line;
}
/** Comments above a statement belong to it; a comment trailing the previous line does not. */
function hasLeadingComment(node: ESTree.Node, sourceCode: SourceCode): boolean {
const previousLine = sourceCode.getTokenBefore(node)?.loc.end.line;
return sourceCode
.getCommentsBefore(node)
.some((comment) => comment.loc.start.line !== previousLine);
}
const singlelineBinding: NodeTestObject = {
test: (node) => isSinglelineBinding(node),
};
const undocumentedSinglelineBinding: NodeTestObject = {
test: (node, sourceCode) => isSinglelineBinding(node) && !hasLeadingComment(node, sourceCode),
};
const paddingRule = createPaddingLineRule(
[
{ blankLine: "always", prev: "import", next: "*" },
{ blankLine: "always", prev: "*", next: { selector: "Program > :not(ImportDeclaration)" } },
{ blankLine: "always", prev: { selector: "Program > :not(ImportDeclaration)" }, next: "*" },
{ blankLine: "always", prev: "*", next: ["function", "class", "interface", "type"] },
{ blankLine: "always", prev: ["function", "class", "interface", "type"], next: "*" },
{
blankLine: "always",
prev: "*",
next: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"],
},
{
blankLine: "always",
prev: ["multiline-const", "multiline-let", "multiline-var", "multiline-using"],
next: "*",
},
{ blankLine: "always", prev: "*", next: ["return", "if", "switch", "try", "for", "while", "do"] },
{ blankLine: "always", prev: "block-like", next: "*" },
{ blankLine: "any", prev: "import", next: "import" },
// Consecutive single-line bindings form one group at any scope, including
// module scope. A binding with its own leading comment starts a new group.
{ blankLine: "any", prev: "singleline-binding", next: "undocumented-singleline-binding" },
{
blankLine: "any",
prev: {
selector:
':matches(TSDeclareFunction, ExportNamedDeclaration[declaration.type="TSDeclareFunction"])',
},
next: {
selector:
':matches(TSDeclareFunction, FunctionDeclaration, ExportNamedDeclaration[declaration.type="TSDeclareFunction"], ExportNamedDeclaration[declaration.type="FunctionDeclaration"])',
},
},
],
{
"singleline-binding": singlelineBinding,
"undocumented-singleline-binding": undocumentedSinglelineBinding,
},
);
/** Restore structural blank lines with whitespace-only fixes; keep single-line binding groups and overloads grouped. */
export const requireReadableSpacingRule: CreateRule = {
...paddingRule,
meta: {
...paddingRule.meta,
docs: {
description: "Require readable spacing between declarations and logical statement groups.",
},
schema: [],
},
};
@@ -0,0 +1,131 @@
import { defineRule } from "@oxlint/plugins";
import type { ESTree, SourceCode } from "@oxlint/plugins";
type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
const DEFAULT_SAFETY_MARKERS = ["SAFETY"] as const;
const commentOwnerKinds = new Set([
"ExpressionStatement",
"PropertyDefinition",
"ReturnStatement",
"ThrowStatement",
"VariableDeclaration",
]);
function isConstAssertion(node: TypeAssertion): boolean {
return (
node.typeAnnotation.type === "TSTypeReference" &&
node.typeAnnotation.typeName.type === "Identifier" &&
node.typeAnnotation.typeName.name === "const"
);
}
function configuredSafetyMarkers(option: unknown): readonly string[] {
if (typeof option !== "object" || option === null || !("markers" in option)) {
return DEFAULT_SAFETY_MARKERS;
}
const configured = option.markers;
if (!Array.isArray(configured)) return DEFAULT_SAFETY_MARKERS;
const markers = configured.flatMap((marker) =>
typeof marker === "string" && marker.trim().length > 0 ? [marker.trim()] : [],
);
return markers.length > 0 ? markers : DEFAULT_SAFETY_MARKERS;
}
function markerPattern(markers: readonly string[]): RegExp {
const alternation = markers
.map((marker) => marker.replaceAll(/[.*+?^${}()|[\]\\]/gu, String.raw`\$&`))
.join("|");
return new RegExp(
String.raw`(?:^|[^\p{L}\p{N}_])(?:${alternation})\s*:\s*\S`,
"u",
);
}
function hasSafetyJustificationBefore(
sourceCode: SourceCode,
owner: ESTree.Node,
assertion: TypeAssertion,
pattern: RegExp,
): boolean {
return sourceCode
.getCommentsBefore(owner)
.some(
(comment) => comment.end <= assertion.start && pattern.test(comment.value),
);
}
function hasSafetyComment(
sourceCode: SourceCode,
node: TypeAssertion,
pattern: RegExp,
): boolean {
let current: ESTree.Node = node;
while (true) {
if (hasSafetyJustificationBefore(sourceCode, current, node, pattern)) return true;
if (commentOwnerKinds.has(current.type)) {
const exportDeclaration = current.parent;
return (
exportDeclaration.type === "ExportNamedDeclaration" &&
exportDeclaration.declaration === current &&
hasSafetyJustificationBefore(sourceCode, exportDeclaration, node, pattern)
);
}
if (current.parent.type === "Program") return false;
current = current.parent;
}
}
/** Require every non-const type assertion to state the invariant TypeScript cannot express. */
export const requireSafetyCommentForTypeAssertionRule = defineRule({
meta: {
type: "problem",
docs: {
description:
"Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.",
},
messages: {
missingSafetyComment:
"This type assertion has no `{{marker}}:` justification. State the checked invariant immediately before the assertion or its containing statement.",
},
schema: [
{
type: "object",
properties: {
markers: {
type: "array",
items: { type: "string", minLength: 1 },
minItems: 1,
uniqueItems: true,
},
},
additionalProperties: false,
},
],
defaultOptions: [{ markers: ["SAFETY"] }],
},
createOnce(context) {
const patterns = new Map<string, RegExp>();
const checkAssertion = (node: TypeAssertion) => {
if (isConstAssertion(node)) return;
const markers = configuredSafetyMarkers(context.options?.[0]);
const patternKey = markers.join("\u0000");
const pattern = patterns.get(patternKey) ?? markerPattern(markers);
patterns.set(patternKey, pattern);
if (hasSafetyComment(context.sourceCode, node, pattern)) return;
context.report({
node,
messageId: "missingSafetyComment",
data: { marker: markers[0] ?? DEFAULT_SAFETY_MARKERS[0] },
});
};
return {
TSAsExpression: checkAssertion,
TSTypeAssertion: checkAssertion,
};
},
});
@@ -0,0 +1,94 @@
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
/** Unwrap syntax-only wrappers when inspecting array methods and accumulator references. */
export function unwrapArrayExpression(node: ESTree.Node): ESTree.Node {
while (
node.type === "ParenthesizedExpression" ||
node.type === "ChainExpression" ||
node.type === "TSAsExpression" ||
node.type === "TSTypeAssertion" ||
node.type === "TSNonNullExpression" ||
node.type === "TSSatisfiesExpression"
) {
node = node.expression;
}
return node;
}
/** Resolve a local binding by scope, not by identifier spelling. */
export function resolveArrayBinding(sourceCode: SourceCode, node: ESTree.Node): Variable | null {
node = unwrapArrayExpression(node);
if (node.type !== "Identifier") return null;
let scope: Scope | null = sourceCode.getScope(node);
while (scope !== null) {
const variable = scope.set.get(node.name);
if (variable !== undefined) return variable;
scope = scope.upper;
}
return null;
}
/** Read static method names, including computed string literals, without evaluating expressions. */
export function arrayMethodTarget(
node: ESTree.Node,
): { readonly name: string; readonly object: ESTree.Node } | null {
node = unwrapArrayExpression(node);
if (node.type !== "MemberExpression") return null;
const property = node.property;
if (!node.computed && property.type === "Identifier") {
return { name: property.name, object: node.object };
}
if (node.computed && property.type === "Literal" && typeof property.value === "string") {
return { name: property.value, object: node.object };
}
return null;
}
function isArrayAnnotation(type: ESTree.TSType): boolean {
if (type.type === "TSArrayType" || type.type === "TSTupleType") return true;
if (type.type === "TSParenthesizedType") return isArrayAnnotation(type.typeAnnotation);
if (type.type === "TSTypeOperator" && type.operator === "readonly") {
return isArrayAnnotation(type.typeAnnotation);
}
return (
type.type === "TSTypeReference" && type.typeName.type === "Identifier" &&
(type.typeName.name === "Array" || type.typeName.name === "ReadonlyArray")
);
}
/** Recognize local array evidence; unknown receivers and iterator pipelines are deliberately excluded. */
export function isKnownArrayExpression(
sourceCode: SourceCode,
node: ESTree.Node,
visited = new Set<Variable>(),
): boolean {
node = unwrapArrayExpression(node);
if (node.type === "ArrayExpression") return true;
if (node.type === "CallExpression") {
const method = arrayMethodTarget(node.callee);
return (
method !== null &&
["map", "filter", "flatMap", "slice", "concat", "toSorted", "toReversed", "toSpliced"].includes(method.name) &&
isKnownArrayExpression(sourceCode, method.object, visited)
);
}
if (node.type !== "Identifier") return false;
const variable = resolveArrayBinding(sourceCode, node);
if (variable === null || visited.has(variable)) return false;
visited.add(variable);
if (variable.references.some(reference => reference.isWrite() && !reference.init)) return false;
for (const identifier of variable.identifiers) {
const annotation = identifier.typeAnnotation?.typeAnnotation;
if (annotation !== undefined) return isArrayAnnotation(annotation);
}
for (const definition of variable.defs) {
if (
definition.type === "Variable" && definition.node.type === "VariableDeclarator" &&
definition.node.id.type === "Identifier" && definition.node.init !== null &&
definition.node.parent.type === "VariableDeclaration" && definition.node.parent.kind === "const"
) {
return isKnownArrayExpression(sourceCode, definition.node.init, visited);
}
}
return false;
}
@@ -0,0 +1,515 @@
import type { ESTree } from "@oxlint/plugins";
import {
createTypeAliasEnvironment,
hasVisibleTypeBinding,
visibleTypeAlias,
type TypeAliasEnvironment as LexicalTypeAliasEnvironment,
} from "./type-alias-resolution.ts";
const BUILT_INS = new Set([
"Record",
"Readonly",
"Partial",
"Required",
"Pick",
"Omit",
"PropertyKey",
"NonNullable",
]);
const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]);
type TypeAliasEnvironment = ReadonlyMap<string, ESTree.TSType>;
type ResolvedType = {
readonly type: ESTree.TSType;
readonly substitutions: TypeAliasEnvironment;
};
export type UnsafeDictionary = {
readonly kind: "unsafe-dictionary";
readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown";
};
export type WideningTargetKind =
| "anonymous object"
| "generic container"
| "object"
| "open dictionary"
| "unknown";
export type WideningTarget = {
readonly kind: WideningTargetKind;
};
export type TypeEnvironment = {
readonly interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
readonly typeAliases: LexicalTypeAliasEnvironment;
};
function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
return statement.type === "ExportNamedDeclaration" ||
statement.type === "ExportDefaultDeclaration"
? (statement.declaration ?? null)
: statement;
}
export function createTypeEnvironment(
program: ESTree.Program,
visitorKeys: Readonly<Record<string, readonly string[]>>,
): TypeEnvironment {
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
for (const statement of program.body) {
const declaration = declaredStatement(statement);
if (declaration?.type !== "TSInterfaceDeclaration") continue;
const declarations = interfaces.get(declaration.id.name) ?? [];
declarations.push(declaration);
interfaces.set(declaration.id.name, declarations);
}
return {
interfaces,
typeAliases: createTypeAliasEnvironment(program, visitorKeys),
};
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function isBuiltIn(
name: string,
use: ESTree.Node,
environment: TypeEnvironment,
): boolean {
return (
BUILT_INS.has(name) &&
!hasVisibleTypeBinding(name, use, environment.typeAliases)
);
}
function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean {
const unwrapped = unwrapTransparentType(type);
return (
unwrapped.type === "TSTypeReference" &&
typeReferenceName(unwrapped) === name &&
(unwrapped.typeArguments === null ||
unwrapped.typeArguments === undefined ||
unwrapped.typeArguments.params.length === 0)
);
}
function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType {
let current = type;
while (
current.type === "TSParenthesizedType" ||
(current.type === "TSTypeOperator" && current.operator === "readonly")
) {
current = current.typeAnnotation;
}
return current;
}
function isNeverType(type: ESTree.TSType): boolean {
return unwrapTransparentType(type).type === "TSNeverKeyword";
}
function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean {
return (
member.type === "TSPropertySignature" &&
member.optional === true &&
member.typeAnnotation !== null &&
member.typeAnnotation !== undefined &&
isNeverType(member.typeAnnotation.typeAnnotation)
);
}
function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean {
return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember);
}
function isEffectivelyEmptyInterface(
declarations: readonly ESTree.TSInterfaceDeclaration[],
): boolean {
if (declarations.length !== 1) return false;
const [type] = declarations;
return (
type !== undefined &&
type.extends.length === 0 &&
(type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember))
);
}
function resolvedSubstitutionArgument(
type: ESTree.TSType,
base: TypeAliasEnvironment,
resolving: ReadonlySet<string> = new Set(),
): ESTree.TSType {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type !== "TSTypeReference") return type;
const name = typeReferenceName(unwrapped);
if (name === null || resolving.has(name)) return type;
const substitution = base.get(name);
if (substitution === undefined) return type;
const nextResolving = new Set(resolving);
nextResolving.add(name);
return resolvedSubstitutionArgument(substitution, base, nextResolving);
}
function aliasSubstitution(
alias: ESTree.TSTypeAliasDeclaration,
type: ESTree.TSTypeReference,
base: TypeAliasEnvironment,
): TypeAliasEnvironment | null {
const parameters = alias.typeParameters?.params ?? [];
const arguments_ = type.typeArguments?.params ?? [];
const next = new Map(base);
for (const [index, parameter] of parameters.entries()) {
const argument = arguments_[index] ?? parameter.default;
if (argument === null || argument === undefined) return null;
next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next));
}
return next;
}
function unsafeDirectValue(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): UnsafeDictionary["unsafeValue"] | null {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSUnknownKeyword") return "unknown";
if (unwrapped.type === "TSAnyKeyword") return "any";
if (unwrapped.type === "TSObjectKeyword") return "object";
if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped))
return "empty-object";
if (unwrapped.type === "TSUnionType") {
return unwrapped.types.some(
(member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null,
)
? "union"
: null;
}
if (unwrapped.type === "TSIntersectionType") {
const unsafeMembers = unwrapped.types.map((member) =>
unsafeDirectValue(member, environment, substitutions, resolvingAliases),
);
if (unsafeMembers.includes("any")) return "any";
return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null)
? unsafeMembers[0]
: null;
}
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? null
: unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases);
}
const substitution = substitutions.get(name);
if (substitution !== undefined) {
return isUnappliedReferenceTo(substitution, name)
? null
: unsafeDirectValue(substitution, environment, substitutions, resolvingAliases);
}
const interfaceDeclarations = environment.interfaces.get(name);
if (interfaceDeclarations !== undefined) {
return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null || resolvingAliases.has(name)) return null;
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return null;
const nextResolving = new Set(resolvingAliases);
nextResolving.add(name);
return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
}
function dictionaryValueTypes(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): readonly ResolvedType[] {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSTypeLiteral") {
return unwrapped.members.flatMap((member): readonly ResolvedType[] =>
member.type === "TSIndexSignature" && member.typeAnnotation !== null
? [{ type: member.typeAnnotation.typeAnnotation, substitutions }]
: [],
);
}
if (unwrapped.type === "TSMappedType") {
return unwrapped.typeAnnotation === null
? []
: [{ type: unwrapped.typeAnnotation, substitutions }];
}
if (unwrapped.type !== "TSTypeReference") return [];
const name = typeReferenceName(unwrapped);
if (name === null) return [];
const substitution = substitutions.get(name);
if (substitution !== undefined) {
return isUnappliedReferenceTo(substitution, name)
? []
: dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases);
}
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? []
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
}
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
const value = unwrapped.typeArguments?.params[1] ?? null;
return value === null ? [] : [{ type: value, substitutions }];
}
if (
(name === "Pick" || name === "Omit") &&
isBuiltIn(name, unwrapped, environment)
) {
const source = unwrapped.typeArguments?.params[0];
return source === undefined
? []
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null || resolvingAliases.has(name)) return [];
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return [];
const nextResolving = new Set(resolvingAliases);
nextResolving.add(name);
return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
}
export function classifyUnsafeDictionaryValue(
valueType: ESTree.TSType,
environment: TypeEnvironment,
): UnsafeDictionary | null {
const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set());
return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue };
}
export function classifyUnsafeDictionary(
type: ESTree.TSType,
environment: TypeEnvironment,
): UnsafeDictionary | null {
for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) {
const unsafeValue = unsafeDirectValue(
valueType.type,
environment,
valueType.substitutions,
new Set(),
);
if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue };
}
return null;
}
export function classifyWideningTarget(
type: ESTree.TSType,
environment: TypeEnvironment,
): WideningTarget | null {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
if (unwrapped.type === "TSTypeLiteral") {
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
? { kind: "open dictionary" }
: unwrapped.members.length > 0
? { kind: "anonymous object" }
: null;
}
if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" };
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
}
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
return hasBroadRecordKey(unwrapped, environment, new Map())
? { kind: "open dictionary" }
: null;
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null) return null;
if ((alias.typeParameters?.params.length ?? 0) > 0) {
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
const resolved =
substitutions === null
? null
: classifyAliasBroadTarget(
alias.typeAnnotation,
environment,
substitutions,
new Set([name]),
);
return resolved?.kind === "open dictionary" ? { kind: "generic container" } : null;
}
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
if (substitutions === null) return null;
const resolved = classifyAliasBroadTarget(
alias.typeAnnotation,
environment,
substitutions,
new Set([name]),
);
return resolved;
}
function hasBroadRecordKey(
type: ESTree.TSTypeReference,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
): boolean {
const key = type.typeArguments?.params[0];
return key === undefined || isBroadMappedKey(key, environment, substitutions);
}
function isBroadMappedKey(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
visitedAliases: ReadonlySet<string> = new Set(),
): boolean {
const unwrapped = unwrapTransparentType(type);
if (
unwrapped.type === "TSStringKeyword" ||
unwrapped.type === "TSNumberKeyword" ||
unwrapped.type === "TSSymbolKeyword"
) {
return true;
}
if (unwrapped.type === "TSUnionType") {
return unwrapped.types.some((member) =>
isBroadMappedKey(member, environment, substitutions, visitedAliases),
);
}
if (unwrapped.type !== "TSTypeReference") return false;
const name = typeReferenceName(unwrapped);
if (name === null) return false;
const substitution = substitutions.get(name);
if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) {
return isBroadMappedKey(substitution, environment, substitutions, visitedAliases);
}
if (name === "PropertyKey" && isBuiltIn(name, unwrapped, environment)) return true;
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (
alias === null ||
(alias.typeParameters?.params.length ?? 0) > 0 ||
visitedAliases.has(name)
) {
return false;
}
const nextVisited = new Set(visitedAliases);
nextVisited.add(name);
return isBroadMappedKey(alias.typeAnnotation, environment, substitutions, nextVisited);
}
function classifyAliasBroadTarget(
type: ESTree.TSType,
environment: TypeEnvironment,
substitutions: TypeAliasEnvironment,
resolvingAliases: ReadonlySet<string>,
): WideningTarget | null {
const unwrapped = unwrapTransparentType(type);
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
if (unwrapped.type === "TSTypeLiteral") {
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
? { kind: "open dictionary" }
: null;
}
if (unwrapped.type === "TSMappedType") {
return isBroadMappedKey(unwrapped.constraint, environment, substitutions)
? { kind: "open dictionary" }
: null;
}
if (unwrapped.type !== "TSTypeReference") return null;
const name = typeReferenceName(unwrapped);
if (name === null) return null;
const substitution = substitutions.get(name);
if (substitution !== undefined) {
return isUnappliedReferenceTo(substitution, name)
? null
: classifyAliasBroadTarget(
substitution,
environment,
substitutions,
resolvingAliases,
);
}
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, unwrapped, environment)) {
const wrapped = unwrapped.typeArguments?.params[0];
return wrapped === undefined
? null
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
}
if (name === "Record" && isBuiltIn(name, unwrapped, environment)) {
return hasBroadRecordKey(unwrapped, environment, substitutions)
? { kind: "open dictionary" }
: null;
}
const alias = visibleTypeAlias(name, unwrapped, environment.typeAliases);
if (alias === null || resolvingAliases.has(name)) return null;
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
if (nextSubstitutions === null) return null;
const nextResolving = new Set(resolvingAliases);
nextResolving.add(name);
return classifyAliasBroadTarget(
alias.typeAnnotation,
environment,
nextSubstitutions,
nextResolving,
);
}
export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean {
let current = expression;
while (
current.type === "ParenthesizedExpression" ||
current.type === "TSAsExpression" ||
current.type === "TSTypeAssertion" ||
current.type === "TSNonNullExpression"
) {
current = current.expression;
}
return current.type === "ObjectExpression" && current.properties.length > 0;
}
export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean {
let current = expression;
while (
current.type === "ParenthesizedExpression" ||
current.type === "TSAsExpression" ||
current.type === "TSTypeAssertion" ||
current.type === "TSNonNullExpression" ||
current.type === "TSSatisfiesExpression"
) {
current = current.expression;
}
if (current.type === "ObjectExpression") return true;
return (
current.type === "ArrayExpression" ||
current.type === "ArrowFunctionExpression" ||
current.type === "ClassExpression" ||
current.type === "FunctionExpression" ||
current.type === "NewExpression" ||
current.type === "Literal" ||
current.type === "TemplateLiteral" ||
current.type === "UnaryExpression"
);
}
@@ -0,0 +1,49 @@
import type { ESTree, SourceCode } from "@oxlint/plugins";
export type FunctionParameter = ESTree.ParamPattern;
/** Return whether a type is or contains TypeScript's absorbing unknown top type. */
export function containsUnknownType(type: ESTree.TSType): boolean {
if (type.type === "TSUnknownKeyword") return true;
if (type.type === "TSParenthesizedType") return containsUnknownType(type.typeAnnotation);
return type.type === "TSUnionType" && type.types.some(containsUnknownType);
}
/** Return the TypeScript annotation attached to a function parameter or its wrapped binding. */
export function functionParameterTypeAnnotation(
parameter: FunctionParameter,
): ESTree.TSTypeAnnotation | null | undefined {
if (parameter.type === "TSParameterProperty") {
return functionParameterTypeAnnotation(parameter.parameter);
}
if (parameter.type === "RestElement") {
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.argument);
}
if (parameter.type === "AssignmentPattern") {
return parameter.typeAnnotation ?? functionParameterTypeAnnotation(parameter.left);
}
return parameter.typeAnnotation;
}
/** Return only a function parameter's local binding, excluding its annotation and default value. */
export function functionParameterBindingName(
parameter: FunctionParameter,
sourceCode: SourceCode,
): string {
if (parameter.type === "TSParameterProperty") {
return functionParameterBindingName(parameter.parameter, sourceCode);
}
if (parameter.type === "AssignmentPattern") {
return functionParameterBindingName(parameter.left, sourceCode);
}
if (parameter.type === "RestElement") {
return functionParameterBindingName(parameter.argument, sourceCode);
}
if (parameter.type === "Identifier") return parameter.name;
const sourceText = sourceCode.getText(parameter);
const annotationStart = parameter.typeAnnotation?.start;
return annotationStart === undefined
? sourceText
: sourceText.slice(0, annotationStart - parameter.start).trimEnd();
}
@@ -0,0 +1,61 @@
import type { ESTree } from "@oxlint/plugins";
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
function isNode(value: unknown): value is ESTree.Node {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function collectInferTypeParameterNames(
node: ESTree.Node,
visitorKeys: VisitorKeys,
names: Set<string>,
): void {
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
const record = node as unknown as Readonly<Record<string, unknown>>;
for (const key of visitorKeys[node.type] ?? []) {
const value = record[key];
if (isNode(value)) {
collectInferTypeParameterNames(value, visitorKeys, names);
continue;
}
if (!Array.isArray(value)) continue;
for (const child of value) {
if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names);
}
}
}
/** Collect type binders that are in scope at a node and can shadow module aliases. */
export function lexicalTypeParameterNames(
node: ESTree.Node,
visitorKeys: VisitorKeys,
): ReadonlySet<string> {
const names = new Set<string>();
let descendant: ESTree.Node = node;
let current: ESTree.Node | null = node;
while (current !== null && current.type !== "Program") {
if ("typeParameters" in current) {
for (const parameter of current.typeParameters?.params ?? []) {
names.add(parameter.name.name);
}
}
if (
current.type === "TSMappedType" &&
(descendant === current.nameType || descendant === current.typeAnnotation)
) {
names.add(current.key.name);
}
if (current.type === "TSConditionalType" && descendant === current.trueType) {
collectInferTypeParameterNames(current.extendsType, visitorKeys, names);
}
descendant = current;
current = current.parent;
}
return names;
}
@@ -0,0 +1,24 @@
import { resolveVariable } from "./scope.ts";
import type { ESTree, SourceCode } from "@oxlint/plugins";
function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean {
if (expression.type !== "Identifier" || expression.name !== "Reflect") return false;
if (sourceCode.isGlobalReference(expression)) return true;
const variable = resolveVariable(sourceCode, expression);
return variable === null || variable.defs.length === 0;
}
/** Reports whether a call target names one method on the global Reflect object. */
export function isGlobalReflectMethodCall(
sourceCode: SourceCode,
callee: ESTree.Expression,
methodName: string,
): boolean {
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
if (!isGlobalReflect(sourceCode, callee.object)) return false;
const property = callee.property;
return callee.computed
? property.type === "Literal" && property.value === methodName
: property.type === "Identifier" && property.name === methodName;
}
@@ -0,0 +1,15 @@
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
/** Resolve an identifier to its binding by walking lexical scopes upward. */
export function resolveVariable(
sourceCode: SourceCode,
identifier: ESTree.IdentifierReference,
): Variable | null {
let scope: Scope | null = sourceCode.getScope(identifier);
while (scope !== null) {
const variable = scope.set.get(identifier.name);
if (variable !== undefined) return variable;
scope = scope.upper;
}
return null;
}
@@ -0,0 +1,250 @@
import type { ESTree } from "@oxlint/plugins";
import { lexicalTypeParameterNames } from "./lexical-type-parameters.ts";
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
type TypeScope = ESTree.Node;
type TypeBinding = {
readonly alias: ESTree.TSTypeAliasDeclaration | null;
readonly name: string;
readonly scope: TypeScope;
};
type Substitution = {
readonly substitutions: Substitutions;
readonly type: ESTree.TSType;
};
type Substitutions = ReadonlyMap<string, Substitution>;
export type TypeAliasEnvironment = {
readonly aliases: readonly ESTree.TSTypeAliasDeclaration[];
readonly bindingsByName: ReadonlyMap<string, readonly TypeBinding[]>;
readonly visitorKeys: VisitorKeys;
};
export type ResolvedTypeMatcher = (
type: ESTree.TSType,
matches: (child: ESTree.TSType) => boolean,
) => boolean;
const environmentsByProgram = new WeakMap<ESTree.Program, TypeAliasEnvironment>();
function isNode(value: unknown): value is ESTree.Node {
return (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
);
}
function enclosingTypeScope(node: ESTree.Node): TypeScope {
let current: ESTree.Node | null = node.parent;
while (current !== null) {
if (
current.type === "Program" ||
current.type === "BlockStatement" ||
current.type === "TSModuleBlock" ||
current.type === "StaticBlock" ||
current.type === "SwitchStatement"
) {
return current;
}
current = current.parent;
}
return node;
}
function declaredTypeBinding(node: ESTree.Node): {
readonly alias: ESTree.TSTypeAliasDeclaration | null;
readonly name: string;
} | null {
if (node.type === "TSTypeAliasDeclaration") {
return { alias: node, name: node.id.name };
}
if (
node.type === "TSInterfaceDeclaration" ||
node.type === "TSEnumDeclaration" ||
node.type === "ClassDeclaration" ||
node.type === "ClassExpression"
) {
return node.id === null ? null : { alias: null, name: node.id.name };
}
if (
node.type === "ImportSpecifier" ||
node.type === "ImportDefaultSpecifier" ||
node.type === "ImportNamespaceSpecifier"
) {
return { alias: null, name: node.local.name };
}
return null;
}
function collectTypeBindings(
node: ESTree.Node,
visitorKeys: VisitorKeys,
bindingsByName: Map<string, TypeBinding[]>,
aliases: ESTree.TSTypeAliasDeclaration[],
): void {
const declared = declaredTypeBinding(node);
if (declared !== null) {
const bindings = bindingsByName.get(declared.name) ?? [];
bindings.push({ ...declared, scope: enclosingTypeScope(node) });
bindingsByName.set(declared.name, bindings);
if (declared.alias !== null) aliases.push(declared.alias);
}
// SAFETY: Oxlint's visitor keys identify only ESTree child-node properties.
const fields = node as unknown as Readonly<Record<string, unknown>>;
for (const key of visitorKeys[node.type] ?? []) {
const value = fields[key];
if (isNode(value)) {
collectTypeBindings(value, visitorKeys, bindingsByName, aliases);
continue;
}
if (!Array.isArray(value)) continue;
for (const child of value) {
if (isNode(child)) {
collectTypeBindings(child, visitorKeys, bindingsByName, aliases);
}
}
}
}
/** Collect every lexical type alias and competing type binding in a program. */
export function createTypeAliasEnvironment(
program: ESTree.Program,
visitorKeys: VisitorKeys,
): TypeAliasEnvironment {
const cached = environmentsByProgram.get(program);
if (cached !== undefined) return cached;
const bindingsByName = new Map<string, TypeBinding[]>();
const aliases: ESTree.TSTypeAliasDeclaration[] = [];
collectTypeBindings(program, visitorKeys, bindingsByName, aliases);
const environment = { aliases, bindingsByName, visitorKeys };
environmentsByProgram.set(program, environment);
return environment;
}
function ancestorDistance(ancestor: ESTree.Node, node: ESTree.Node): number | null {
let current: ESTree.Node | null = node;
let distance = 0;
while (current !== null) {
if (current === ancestor) return distance;
current = current.parent;
distance += 1;
}
return null;
}
function nearestTypeBindings(
name: string,
use: ESTree.Node,
environment: TypeAliasEnvironment,
): readonly TypeBinding[] {
const candidates = environment.bindingsByName.get(name) ?? [];
let nearestDistance = Number.POSITIVE_INFINITY;
let nearest: TypeBinding[] = [];
for (const candidate of candidates) {
const distance = ancestorDistance(candidate.scope, use);
if (distance === null || distance > nearestDistance) continue;
if (distance === nearestDistance) {
nearest.push(candidate);
continue;
}
nearestDistance = distance;
nearest = [candidate];
}
return nearest;
}
/** Resolve the nearest visible alias with this name, respecting lexical shadowing. */
export function visibleTypeAlias(
name: string,
use: ESTree.Node,
environment: TypeAliasEnvironment,
): ESTree.TSTypeAliasDeclaration | null {
if (lexicalTypeParameterNames(use, environment.visitorKeys).has(name)) return null;
const bindings = nearestTypeBindings(name, use, environment);
return bindings.length === 1 ? (bindings[0]?.alias ?? null) : null;
}
/** Return whether a local declaration shadows a built-in type at this use. */
export function hasVisibleTypeBinding(
name: string,
use: ESTree.Node,
environment: TypeAliasEnvironment,
): boolean {
return (
lexicalTypeParameterNames(use, environment.visitorKeys).has(name) ||
nearestTypeBindings(name, use, environment).length > 0
);
}
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
return type.typeName.type === "Identifier" ? type.typeName.name : null;
}
function aliasSubstitutions(
alias: ESTree.TSTypeAliasDeclaration,
reference: ESTree.TSTypeReference,
base: Substitutions,
): Substitutions | null {
const parameters = alias.typeParameters?.params ?? [];
const arguments_ = reference.typeArguments?.params ?? [];
const next = new Map(base);
for (const [index, parameter] of parameters.entries()) {
const explicitArgument = arguments_[index];
const argument = explicitArgument ?? parameter.default;
if (argument === null || argument === undefined) return null;
const argumentSubstitutions = explicitArgument === undefined ? next : base;
next.set(parameter.name.name, {
type: argument,
substitutions: new Map(argumentSubstitutions),
});
}
return next;
}
/** Match a type after resolving visible aliases and substituting their type parameters. */
export function resolvedTypeMatches(
type: ESTree.TSType,
environment: TypeAliasEnvironment,
matcher: ResolvedTypeMatcher,
): boolean {
const evaluate = (
current: ESTree.TSType,
substitutions: Substitutions,
resolvingAliases: ReadonlySet<ESTree.TSTypeAliasDeclaration>,
): boolean => {
if (current.type === "TSTypeReference") {
const name = typeReferenceName(current);
if (name !== null) {
const substitution = substitutions.get(name);
if (substitution !== undefined && !current.typeArguments?.params.length) {
return evaluate(
substitution.type,
substitution.substitutions,
resolvingAliases,
);
}
const alias = visibleTypeAlias(name, current, environment);
if (alias !== null && !resolvingAliases.has(alias)) {
const nextSubstitutions = aliasSubstitutions(alias, current, substitutions);
if (nextSubstitutions !== null) {
const nextResolving = new Set(resolvingAliases);
nextResolving.add(alias);
return evaluate(alias.typeAnnotation, nextSubstitutions, nextResolving);
}
}
}
}
return matcher(current, (child) =>
evaluate(child, substitutions, resolvingAliases),
);
};
return evaluate(type, new Map(), new Set());
}
@@ -0,0 +1,22 @@
MIT License
Copyright OpenJS Foundation and other contributors, <www.openjsf.org>
Copyright (c) 2023-PRESENT ESLint Stylistic contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,28 @@
# Vendored padding-line-between-statements
Source: [ESLint Stylistic](https://github.com/eslint-stylistic/eslint-stylistic), commit `435c3ea0fd26a5fef9042c4b36b6e165fbbf8d08`.
Copied files:
- `packages/eslint-plugin/rules/padding-line-between-statements/padding-line-between-statements.ts`
- `packages/eslint-plugin/rules/padding-line-between-statements/types.d.ts` → `padding-line-options.d.ts`
- Root `LICENSE`, retained verbatim. Both OpenJS Foundation and ESLint Stylistic notices apply.
The rule is MIT-licensed. Keep `LICENSE` with every redistributed copy, including skill assets. No Stylistic, ESLint, TypeScript-ESLint, or additional parser runtime dependency is required.
## Local adaptations
- Replace upstream type aliases with Oxlint's ESTree, context, token/comment, and rule types. Upstream's token type includes comments; Oxlint exposes those separately.
- Replace `AST_NODE_TYPES` enum members with identical string literals.
- Guard indexed reads for consuming repositories with `noUncheckedIndexedAccess`. Impossible missing AST/configuration entries raise explicit invariant errors rather than introducing new non-null assertions.
- Replace the repository-specific `createRule` factory with `createPaddingLineRule(options, statementTypes?)`. The anti-slop wrapper supplies typed options directly; it exposes no user configuration options. The optional second argument registers caller-defined statement types, consulted before the upstream table, so a policy can match syntax the upstream matchers do not distinguish (exported bindings, comment ownership) without editing the vendored tables.
- Implement the small required AST helper surface in `padding-line-ast.ts` using Oxlint's public source-code/token API. `isParenthesized` only needs the one-pair check used to exclude parenthesized directive strings, not the upstream general-purpose overloads.
- Retain the upstream statement matchers, scope tracking, comment-aware insertion/removal, selector support, and diagnostic text. Upstream naming and non-null assumptions remain localized here to keep future diffs reviewable; the file is not a model for new application code.
The opinionated policy lives outside this directory in `../../rules/require-readable-spacing.ts`. It adds spacing without collapsing existing blank lines. Short local bindings, consecutive imports, and adjacent overload signatures/implementation remain grouped. Spacing is syntactic, not an inference of business-logic boundaries.
## Updating and verification
Fetch an explicit upstream revision, compare the original rule and types against this revision, and port relevant fixes while retaining the adapters above. Update this record and preserve the license. Run `pnpm check` and `pnpm sync:skill-assets` as required by repository guidance.
Focused Oxlint RuleTester cases live in `../../rules/require-readable-spacing.test.ts`; they test exact fixes, JSDoc/trailing comments, same-line statements, semicolon-free code, TypeScript exports/overloads, Effect-style generators, and upstream removal behavior. `../../rules/require-readable-spacing-cli.test.ts` verifies the exported plugin through the native Oxlint CLI on multiple files, including rejection, autofix, and repeated-fix stability. The complete upstream JS/TS test suites have not been ported; this is focused compatibility evidence, not a claim of full upstream conformance.
@@ -0,0 +1,51 @@
// Local replacements for the upstream helper imports. See UPSTREAM.md.
import type { ESTree, SourceCode, Token as SyntaxToken, Comment, Location } from "@oxlint/plugins";
type Token = SyntaxToken | Comment;
/** Line terminators recognized by the upstream padding matcher. */
export const LINEBREAKS = new Set(["\r\n", "\r", "\n", "\u2028", "\u2029"]);
/** Test a closing brace without treating comment text as punctuation. */
export const isClosingBraceToken = (token: Token): boolean =>
token.type === "Punctuator" && token.value === "}";
/** Test a semicolon without treating comment text as punctuation. */
export const isSemicolonToken = (token: Token): boolean =>
token.type === "Punctuator" && token.value === ";";
/** Filter the optional final semicolon when identifying block-like statements. */
export const isNotSemicolonToken = (token: Token): boolean => !isSemicolonToken(token);
/** Compare token/node boundaries, including attached comments. */
export const isTokenOnSameLine = (left: { loc: Location }, right: { loc: Location }): boolean =>
left.loc.end.line === right.loc.start.line;
/** Recognize declarations and expressions used by the upstream IIFE matcher. */
export const isFunction = (node: ESTree.Node): boolean =>
node.type === "FunctionDeclaration" ||
node.type === "FunctionExpression" ||
node.type === "ArrowFunctionExpression";
/** Preserve the upstream multiline statement heuristic. */
export const isSingleLine = (node: ESTree.Node): boolean =>
node.loc.start.line === node.loc.end.line;
/** Unwrap optional chaining before checking IIFE syntax. */
export const skipChainExpression = (node: ESTree.Node): ESTree.Node =>
node.type === "ChainExpression" ? node.expression : node;
/** Only a program or function-body expression can begin a directive prologue. */
export const isTopLevelExpressionStatement = (
node: ESTree.Node,
): node is ESTree.ExpressionStatement =>
node.type === "ExpressionStatement" &&
(node.parent.type === "Program" ||
(node.parent.type === "BlockStatement" && isFunction(node.parent.parent)));
/** A single wrapping pair suffices to exclude a string from directive syntax. */
export function isParenthesized(node: ESTree.Node, sourceCode: SourceCode): boolean {
const before = sourceCode.getTokenBefore(node);
const after = sourceCode.getTokenAfter(node);
return before?.value === "(" && after?.value === ")";
}
@@ -0,0 +1,929 @@
// Vendored from ESLint Stylistic; see UPSTREAM.md and LICENSE in this directory.
import type { ESTree, Context as RuleContext, SourceCode, Token as SyntaxToken, Comment, CreateRule, Location } from '@oxlint/plugins'
type ASTNode = ESTree.Node
type Token = SyntaxToken | Comment
import type {
PaddingType,
SelectorOption,
StatementMatcher,
} from './padding-line-options.d.ts'
import {
isClosingBraceToken,
isFunction,
isNotSemicolonToken,
isParenthesized,
isSemicolonToken,
isSingleLine,
isTokenOnSameLine,
isTopLevelExpressionStatement,
LINEBREAKS,
skipChainExpression,
} from './padding-line-ast.ts'
const CJS_EXPORT = /^(?:module\s*\.\s*)?exports(?:\s*\.|\s*\[|$)/u
const CJS_IMPORT = /^require\(/u
/**
* This rule is a replica of padding-line-between-statements.
*
* Ideally we would want to extend the rule support typescript specific support.
* But since not all the state is exposed by the eslint and eslint has frozen stylistic rules,
* (see - https://eslint.org/blog/2020/05/changes-to-rules-policies for details.)
* we are forced to re-implement the rule here.
*
* We have tried to keep the implementation as close as possible to the eslint implementation, to make
* patching easier for future contributors.
*
* Reference rule - https://github.com/eslint/eslint/blob/main/lib/rules/padding-line-between-statements.js
*/
type NodeTest = (
node: ASTNode,
sourceCode: SourceCode,
) => boolean
export interface NodeTestObject {
test: NodeTest
}
/**
* A policy entry may name a caller-defined statement type (registered through
* `createPaddingLineRule`) anywhere the upstream statement types are accepted.
*/
export type PolicyStatementOption<Extra extends string = never> =
| StatementMatcher
| Extra
| [StatementMatcher | Extra, ...(StatementMatcher | Extra)[]]
export type PolicyOptions<Extra extends string = never> = {
blankLine: PaddingType
prev: PolicyStatementOption<Extra>
next: PolicyStatementOption<Extra>
}[]
const LT = `[${Array.from(LINEBREAKS).join('')}]`
const PADDING_LINE_SEQUENCE = new RegExp(
String.raw`^(\s*?${LT})\s*${LT}(\s*;?)$`,
'u',
)
function isSelectorOption(option: PolicyStatementOption<string>): option is SelectorOption {
return typeof option === 'object' && !Array.isArray(option)
}
/**
* Creates tester which check if a node starts with specific keyword with the
* appropriate AST_NODE_TYPES.
* @param keyword The keyword to test.
* @returns the created tester.
* @private
*/
function newKeywordTester(
type: string | string[],
keyword: string,
): NodeTestObject {
return {
test(node, sourceCode): boolean {
const isSameKeyword = sourceCode.getFirstToken(node)?.value === keyword
const isSameType = Array.isArray(type)
? type.includes(node.type)
: type === node.type
return isSameKeyword && isSameType
},
}
}
/**
* Creates tester which check if a node is specific type.
* @param type The node type to test.
* @returns the created tester.
* @private
*/
function newNodeTypeTester(type: string): NodeTestObject {
return {
test: (node): boolean => node.type === type,
}
}
/**
* Checks the given node is an expression statement of IIFE.
* @param node The node to check.
* @returns `true` if the node is an expression statement of IIFE.
* @private
*/
function isIIFEStatement(node: ASTNode): boolean {
if (node.type === 'ExpressionStatement') {
let expression = skipChainExpression(node.expression)
if (expression.type === 'UnaryExpression')
expression = skipChainExpression(expression.argument)
if (expression.type === 'CallExpression') {
let node: ASTNode = expression.callee
while (node.type === 'SequenceExpression') {
const lastExpression = node.expressions.at(-1)
if (lastExpression === undefined)
throw new Error('Padding rule invariant: sequence expression is empty')
node = lastExpression
}
return isFunction(node)
}
}
return false
}
/**
* Checks the given node is a CommonJS require statement
* @param node The node to check.
* @returns `true` if the node is a CommonJS require statement.
* @private
*/
function isCJSRequire(node: ASTNode): boolean {
if (node.type === 'VariableDeclaration') {
const declaration = node.declarations[0]
if (declaration?.init) {
let call = declaration?.init
while (call.type === 'MemberExpression')
call = call.object
if (
call.type === 'CallExpression'
&& call.callee.type === 'Identifier'
) {
return call.callee.name === 'require'
}
}
}
return false
}
/**
* Checks whether the given node is a block-like statement.
* This checks the last token of the node is the closing brace of a block.
* @param sourceCode The source code to get tokens.
* @param node The node to check.
* @returns `true` if the node is a block-like statement.
* @private
*/
function isBlockLikeStatement(
node: ASTNode,
sourceCode: SourceCode,
): boolean {
// do-while with a block is a block-like statement.
if (
node.type === 'DoWhileStatement'
&& node.body.type === 'BlockStatement'
) {
return true
}
/**
* IIFE is a block-like statement specially from
* JSCS#disallowPaddingNewLinesAfterBlocks.
*/
if (isIIFEStatement(node))
return true
// Checks the last token is a closing brace of blocks.
const lastToken = sourceCode.getLastToken(node, isNotSemicolonToken)
const belongingNode
= lastToken && isClosingBraceToken(lastToken)
? sourceCode.getNodeByRangeIndex(lastToken.range[0])
: null
return (
!!belongingNode
&& (belongingNode.type === 'BlockStatement'
|| belongingNode.type === 'SwitchStatement')
)
}
/**
* Check whether the given node is a directive or not.
* @param node The node to check.
* @param sourceCode The source code object to get tokens.
* @returns `true` if the node is a directive.
*/
function isDirective(
node: ASTNode,
sourceCode: SourceCode,
): boolean {
return (
isTopLevelExpressionStatement(node)
&& node.expression.type === 'Literal'
&& typeof node.expression.value === 'string'
&& !isParenthesized(node.expression, sourceCode)
)
}
/**
* Check whether the given node is a part of directive prologue or not.
* @param node The node to check.
* @param sourceCode The source code object to get tokens.
* @returns `true` if the node is a part of directive prologue.
*/
function isDirectivePrologue(
node: ASTNode,
sourceCode: SourceCode,
): boolean {
if (
isDirective(node, sourceCode)
&& node.parent
&& 'body' in node.parent
&& Array.isArray(node.parent.body)
) {
for (const sibling of node.parent.body) {
if (sibling === node)
break
if (!isDirective(sibling, sourceCode))
return false
}
return true
}
return false
}
/**
* Checks the given node is a CommonJS export statement
* @param node The node to check.
* @returns `true` if the node is a CommonJS export statement.
* @private
*/
function isCJSExport(node: ASTNode): boolean {
if (node.type === 'ExpressionStatement') {
const expression = node.expression
if (expression.type === 'AssignmentExpression') {
let left = expression.left
if (left.type === 'MemberExpression') {
while (left.object.type === 'MemberExpression')
left = left.object
return (
left.object.type === 'Identifier'
&& (left.object.name === 'exports'
|| (left.object.name === 'module'
&& left.property.type === 'Identifier'
&& left.property.name === 'exports'))
)
}
}
}
return false
}
/**
* Check whether the given node is an expression
* @param node The node to check.
* @param sourceCode The source code object to get tokens.
* @returns `true` if the node is an expression
*/
function isExpression(
node: ASTNode,
sourceCode: SourceCode,
): boolean {
return (
node.type === 'ExpressionStatement'
&& !isDirectivePrologue(node, sourceCode)
)
}
/**
* Gets the actual last token.
*
* If a semicolon is semicolon-less style's semicolon, this ignores it.
* For example:
*
* foo()
* ;[1, 2, 3].forEach(bar)
* @param sourceCode The source code to get tokens.
* @param node The node to get.
* @returns The actual last token.
* @private
*/
function getActualLastToken(
node: ASTNode,
sourceCode: SourceCode,
): Token | null {
const semiToken = sourceCode.getLastToken(node)!
const prevToken = sourceCode.getTokenBefore(semiToken)
const nextToken = sourceCode.getTokenAfter(semiToken)
const isSemicolonLessStyle
= prevToken
&& nextToken
&& prevToken.range[0] >= node.range[0]
&& isSemicolonToken(semiToken)
&& !isTokenOnSameLine(prevToken, semiToken)
&& isTokenOnSameLine(semiToken, nextToken)
return isSemicolonLessStyle ? prevToken : semiToken
}
/**
* This returns the concatenation of the first 2 captured strings.
* @param _ Unused. Whole matched string.
* @param trailingSpaces The trailing spaces of the first line.
* @param indentSpaces The indentation spaces of the last line.
* @returns The concatenation of trailingSpaces and indentSpaces.
* @private
*/
function replacerToRemovePaddingLines(
_: string,
trailingSpaces: string,
indentSpaces: string,
): string {
return trailingSpaces + indentSpaces
}
function getReportLoc(node: ASTNode, sourceCode: SourceCode): Location {
if (isSingleLine(node))
return node.loc
const line = node.loc.start.line
const sourceLine = sourceCode.lines[line - 1]
if (sourceLine === undefined)
throw new Error('Padding rule invariant: statement source line is missing')
return {
start: node.loc.start,
end: {
line,
column: sourceLine.length,
},
}
}
/**
* Check and report statements for `any` configuration.
* It does nothing.
*
* @private
*/
function verifyForAny(): void {
// Empty
}
/**
* Check and report statements for `never` configuration.
* This autofix removes blank lines between the given 2 statements.
* However, if comments exist between 2 blank lines, it does not remove those
* blank lines automatically.
* @param context The rule context to report.
* @param _ Unused. The previous node to check.
* @param nextNode The next node to check.
* @param paddingLines The array of token pairs that blank
* lines exist between the pair.
*
* @private
*/
function verifyForNever(
context: RuleContext,
_: ASTNode,
nextNode: ASTNode,
paddingLines: [Token, Token][],
): void {
if (paddingLines.length === 0)
return
context.report({
node: nextNode,
messageId: 'unexpectedBlankLine',
loc: getReportLoc(nextNode, context.sourceCode),
fix(fixer) {
if (paddingLines.length >= 2)
return null
const paddingPair = paddingLines[0]
if (paddingPair === undefined)
throw new Error('Padding rule invariant: reported padding pair is missing')
const [prevToken, nextToken] = paddingPair
const start = prevToken.range[1]
const end = nextToken.range[0]
const text = context
.sourceCode
.text
.slice(start, end)
.replace(PADDING_LINE_SEQUENCE, replacerToRemovePaddingLines)
return fixer.replaceTextRange([start, end], text)
},
})
}
/**
* Check and report statements for `always` configuration.
* This autofix inserts a blank line between the given 2 statements.
* If the `prevNode` has trailing comments, it inserts a blank line after the
* trailing comments.
* @param context The rule context to report.
* @param prevNode The previous node to check.
* @param nextNode The next node to check.
* @param paddingLines The array of token pairs that blank
* lines exist between the pair.
*
* @private
*/
function verifyForAlways(
context: RuleContext,
prevNode: ASTNode,
nextNode: ASTNode,
paddingLines: [Token, Token][],
): void {
if (paddingLines.length > 0)
return
context.report({
node: nextNode,
messageId: 'expectedBlankLine',
loc: getReportLoc(nextNode, context.sourceCode),
fix(fixer) {
const sourceCode = context.sourceCode
let prevToken = getActualLastToken(prevNode, sourceCode)!
const nextToken
= sourceCode.getFirstTokenBetween(prevToken, nextNode, {
includeComments: true,
/**
* Skip the trailing comments of the previous node.
* This inserts a blank line after the last trailing comment.
*
* For example:
*
* foo(); // trailing comment.
* // comment.
* bar();
*
* Get fixed to:
*
* foo(); // trailing comment.
*
* // comment.
* bar();
* @param token The token to check.
* @returns `true` if the token is not a trailing comment.
* @private
*/
filter(token) {
if (isTokenOnSameLine(prevToken, token)) {
prevToken = token
return false
}
return true
},
})! || nextNode
const insertText = isTokenOnSameLine(prevToken, nextToken)
? '\n\n'
: '\n'
return fixer.insertTextAfter(prevToken, insertText)
},
})
}
/**
* Types of blank lines.
* `any`, `never`, and `always` are defined.
* Those have `verify` method to check and report statements.
* @private
*/
const PaddingTypes = {
any: { verify: verifyForAny },
never: { verify: verifyForNever },
always: { verify: verifyForAlways },
}
const MaybeMultilineStatementType: Record<string, NodeTestObject> = {
'block-like': { test: isBlockLikeStatement },
'expression': { test: isExpression },
'return': newKeywordTester('ReturnStatement', 'return'),
'export': newKeywordTester(
[
'ExportAllDeclaration',
'ExportDefaultDeclaration',
'ExportNamedDeclaration',
],
'export',
),
'var': newKeywordTester('VariableDeclaration', 'var'),
'let': newKeywordTester('VariableDeclaration', 'let'),
'const': newKeywordTester('VariableDeclaration', 'const'),
'using': {
test: node => node.type === 'VariableDeclaration'
&& (node.kind === 'using' || node.kind === 'await using'),
},
'type': newKeywordTester('TSTypeAliasDeclaration', 'type'),
}
/**
* Types of statements.
* Those have `test` method to check it matches to the given statement.
* @private
*/
const StatementTypes: Record<string, NodeTestObject> = {
'*': { test: (): boolean => true },
'exports': { test: isCJSExport },
'require': { test: isCJSRequire },
'directive': { test: isDirectivePrologue },
'iife': { test: isIIFEStatement },
'block': newNodeTypeTester('BlockStatement'),
'empty': newNodeTypeTester('EmptyStatement'),
'function': newNodeTypeTester('FunctionDeclaration'),
'ts-method': newNodeTypeTester('TSMethodSignature'),
'break': newKeywordTester('BreakStatement', 'break'),
'case': newKeywordTester('SwitchCase', 'case'),
'class': newKeywordTester('ClassDeclaration', 'class'),
'continue': newKeywordTester('ContinueStatement', 'continue'),
'debugger': newKeywordTester('DebuggerStatement', 'debugger'),
'default': newKeywordTester(
['SwitchCase', 'ExportDefaultDeclaration'],
'default',
),
'do': newKeywordTester('DoWhileStatement', 'do'),
'for': newKeywordTester(
[
'ForStatement',
'ForInStatement',
'ForOfStatement',
],
'for',
),
'if': newKeywordTester('IfStatement', 'if'),
'import': newKeywordTester('ImportDeclaration', 'import'),
'switch': newKeywordTester('SwitchStatement', 'switch'),
'throw': newKeywordTester('ThrowStatement', 'throw'),
'try': newKeywordTester('TryStatement', 'try'),
'while': newKeywordTester(
['WhileStatement', 'DoWhileStatement'],
'while',
),
'with': newKeywordTester('WithStatement', 'with'),
'cjs-export': {
test: (node, sourceCode) => node.type === 'ExpressionStatement'
&& node.expression.type === 'AssignmentExpression'
&& CJS_EXPORT.test(sourceCode.getText(node.expression.left)),
},
'cjs-import': {
test: (node, sourceCode) => node.type === 'VariableDeclaration'
&& node.declarations.length > 0
&& node.declarations[0]?.init != null
&& CJS_IMPORT.test(sourceCode.getText(node.declarations[0].init)),
},
'enum': newKeywordTester(
'TSEnumDeclaration',
'enum',
),
'interface': newKeywordTester(
'TSInterfaceDeclaration',
'interface',
),
'function-overload': newNodeTypeTester('TSDeclareFunction'),
...Object.fromEntries(
Object.entries(MaybeMultilineStatementType)
.flatMap(([key, value]) => [
[key, value],
[
`singleline-${key}`,
{
...value,
test: (node, sourceCode) => value.test(node, sourceCode) && isSingleLine(node),
},
],
[
`multiline-${key}`,
{
...value,
test: (node, sourceCode) => value.test(node, sourceCode) && !isSingleLine(node),
},
],
]),
),
}
/**
* Build the vendored padding rule with caller-owned, typed policy options.
* `statementTypes` registers caller-defined statement types, looked up before
* the upstream table, for syntax the upstream matchers do not distinguish.
*/
export default function createPaddingLineRule<Extra extends string = never>(
options: PolicyOptions<NoInfer<Extra>>,
statementTypes?: Record<Extra, NodeTestObject>,
): CreateRule {
const customStatementTypes: Partial<Record<string, NodeTestObject>> = statementTypes ?? {}
return {
meta: {
type: 'layout',
docs: {
description: 'Require or disallow padding lines between statements',
},
fixable: 'whitespace',
hasSuggestions: false,
// This is intentionally an array schema as you can pass 0..n config objects
schema: {
$defs: {
paddingType: {
type: 'string',
enum: Object.keys(PaddingTypes),
},
statementType: {
type: 'string',
enum: Object.keys(StatementTypes),
},
selectorOption: {
type: 'object',
properties: {
selector: {
type: 'string',
},
lineMode: {
type: 'string',
enum: ['any', 'singleline', 'multiline'],
},
},
required: ['selector'],
additionalProperties: false,
},
statementMatcher: {
anyOf: [
{ $ref: '#/$defs/statementType' },
{ $ref: '#/$defs/selectorOption' },
],
},
statementOption: {
anyOf: [
{ $ref: '#/$defs/statementMatcher' },
{
type: 'array',
items: { $ref: '#/$defs/statementMatcher' },
minItems: 1,
uniqueItems: true,
additionalItems: false,
},
],
},
},
type: 'array',
additionalItems: false,
items: {
type: 'object',
properties: {
blankLine: { $ref: '#/$defs/paddingType' },
prev: { $ref: '#/$defs/statementOption' },
next: { $ref: '#/$defs/statementOption' },
},
additionalProperties: false,
required: ['blankLine', 'prev', 'next'],
},
},
messages: {
unexpectedBlankLine: 'Unexpected blank line before this statement.',
expectedBlankLine: 'Expected blank line before this statement.',
},
},
create(context) {
const sourceCode = context.sourceCode
const selectorMatchedNodes = new Map<string, Set<ASTNode>>()
const pendingPairs: { prevNode: ASTNode, nextNode: ASTNode }[] = []
function collectSelectorOption(option: PolicyStatementOption<Extra>): void {
if (Array.isArray(option)) {
for (const item of option)
collectSelectorOption(item)
return
}
if (!isSelectorOption(option))
return
selectorMatchedNodes.set(option.selector, new Set())
}
for (const configure of options) {
collectSelectorOption(configure.prev)
collectSelectorOption(configure.next)
}
type Scope = {
upper: Scope
prevNode: ASTNode | null
} | null
let scopeInfo: Scope = null
/**
* Processes to enter to new scope.
* This manages the current previous statement.
*
* @private
*/
function enterScope(): void {
scopeInfo = {
upper: scopeInfo,
prevNode: null,
}
}
/**
* Processes to exit from the current scope.
*
* @private
*/
function exitScope(): void {
if (scopeInfo)
scopeInfo = scopeInfo.upper
}
/**
* Checks whether the given node matches the given type.
* @param node The statement node to check.
* @param type The statement type to check.
* @returns `true` if the statement node matched the type.
* @private
*/
function match(node: ASTNode, type: PolicyStatementOption<Extra>): boolean {
let innerStatementNode = node
while (innerStatementNode.type === 'LabeledStatement')
innerStatementNode = innerStatementNode.body
if (Array.isArray(type))
return type.some(match.bind(null, innerStatementNode))
if (isSelectorOption(type)) {
const matchedNodes = selectorMatchedNodes.get(type.selector)
if (!matchedNodes?.has(innerStatementNode))
return false
const lineMode = type.lineMode
if (lineMode === 'singleline')
return isSingleLine(innerStatementNode)
else if (lineMode === 'multiline')
return !isSingleLine(innerStatementNode)
return true
}
else {
const statementType = customStatementTypes[type] ?? StatementTypes[type]
if (statementType === undefined)
throw new Error(`Padding rule invariant: unsupported statement type ${type}`)
return statementType.test(innerStatementNode, sourceCode)
}
}
/**
* Finds the last matched configure from options.
* @param prevNode The previous statement to match.
* @param nextNode The current statement to match.
* @returns The tester of the last matched configure.
* @private
*/
function getPaddingType(
prevNode: ASTNode,
nextNode: ASTNode,
): (typeof PaddingTypes)[keyof typeof PaddingTypes] {
for (let i = options.length - 1; i >= 0; --i) {
const configure = options[i]
if (configure === undefined)
throw new Error('Padding rule invariant: configuration entry is missing')
if (
match(prevNode, configure.prev)
&& match(nextNode, configure.next)
) {
return PaddingTypes[configure.blankLine]
}
}
return PaddingTypes.any
}
/**
* Gets padding line sequences between the given 2 statements.
* Comments are separators of the padding line sequences.
* @param prevNode The previous statement to count.
* @param nextNode The current statement to count.
* @returns The array of token pairs.
* @private
*/
function getPaddingLineSequences(
prevNode: ASTNode,
nextNode: ASTNode,
): [Token, Token][] {
const pairs: [Token, Token][] = []
let prevToken: Token = getActualLastToken(prevNode, sourceCode)!
if (nextNode.loc.start.line - prevToken.loc.end.line >= 2) {
do {
const token: Token = sourceCode.getTokenAfter(prevToken, {
includeComments: true,
})!
if (token.loc.start.line - prevToken.loc.end.line >= 2)
pairs.push([prevToken, token])
prevToken = token
} while (prevToken.range[0] < nextNode.range[0])
}
return pairs
}
/**
* Verify padding lines between the given node and the previous node.
* @param node The node to verify.
*
* @private
*/
function verify(node: ASTNode): void {
if (
!node.parent
|| ![
'BlockStatement',
'Program',
'StaticBlock',
'SwitchCase',
'SwitchStatement',
'TSInterfaceBody',
'TSModuleBlock',
'TSTypeLiteral',
].includes(node.parent.type)
) {
return
}
// Save this node as the current previous statement.
const prevNode = scopeInfo!.prevNode
// Verify.
if (prevNode)
pendingPairs.push({ prevNode, nextNode: node })
scopeInfo!.prevNode = node
}
function verifyPendingPairs(): void {
for (const { prevNode, nextNode } of pendingPairs) {
const type = getPaddingType(prevNode, nextNode)
const paddingLines = getPaddingLineSequences(prevNode, nextNode)
type.verify(context, prevNode, nextNode, paddingLines)
}
}
/**
* Verify padding lines between the given node and the previous node.
* Then process to enter to new scope.
* @param node The node to verify.
*
* @private
*/
function verifyThenEnterScope(node: ASTNode): void {
verify(node)
enterScope()
}
const selectorMatchListeners = Object.fromEntries(
Array.from(selectorMatchedNodes.keys(), selector => [
selector,
(node: ASTNode): void => {
selectorMatchedNodes.get(selector)?.add(node)
},
]),
)
return {
'Program': enterScope,
'Program:exit': () => {
verifyPendingPairs()
exitScope()
},
'BlockStatement': enterScope,
'BlockStatement:exit': exitScope,
'SwitchStatement': enterScope,
'SwitchStatement:exit': exitScope,
'SwitchCase': verifyThenEnterScope,
'SwitchCase:exit': exitScope,
'StaticBlock': enterScope,
'StaticBlock:exit': exitScope,
'TSInterfaceBody': enterScope,
'TSInterfaceBody:exit': exitScope,
'TSModuleBlock': enterScope,
'TSModuleBlock:exit': exitScope,
'TSTypeLiteral': enterScope,
'TSTypeLiteral:exit': exitScope,
'TSDeclareFunction': verifyThenEnterScope,
'TSDeclareFunction:exit': exitScope,
'TSMethodSignature': verifyThenEnterScope,
'TSMethodSignature:exit': exitScope,
':statement': verify,
...selectorMatchListeners,
}
},
}
}
@@ -0,0 +1,87 @@
/* GENERATED, DO NOT EDIT DIRECTLY */
/* @checksum: 3QCTtOH6rJM5_AGJ58rGpeEaBEfaJz17MSCxWB4X_PU */
export type PaddingType = 'any' | 'never' | 'always'
export type StatementOption =
| StatementMatcher
| [StatementMatcher, ...StatementMatcher[]]
export type StatementMatcher =
| StatementType
| SelectorOption
export type StatementType =
| '*'
| 'exports'
| 'require'
| 'directive'
| 'iife'
| 'block'
| 'empty'
| 'function'
| 'ts-method'
| 'break'
| 'case'
| 'class'
| 'continue'
| 'debugger'
| 'default'
| 'do'
| 'for'
| 'if'
| 'import'
| 'switch'
| 'throw'
| 'try'
| 'while'
| 'with'
| 'cjs-export'
| 'cjs-import'
| 'enum'
| 'interface'
| 'function-overload'
| 'block-like'
| 'singleline-block-like'
| 'multiline-block-like'
| 'expression'
| 'singleline-expression'
| 'multiline-expression'
| 'return'
| 'singleline-return'
| 'multiline-return'
| 'export'
| 'singleline-export'
| 'multiline-export'
| 'var'
| 'singleline-var'
| 'multiline-var'
| 'let'
| 'singleline-let'
| 'multiline-let'
| 'const'
| 'singleline-const'
| 'multiline-const'
| 'using'
| 'singleline-using'
| 'multiline-using'
| 'type'
| 'singleline-type'
| 'multiline-type'
export type PaddingLineBetweenStatementsSchema0 = {
blankLine: PaddingType
prev: StatementOption
next: StatementOption
}[]
export interface SelectorOption {
selector: string
lineMode?: 'any' | 'singleline' | 'multiline'
}
export type PaddingLineBetweenStatementsRuleOptions
= PaddingLineBetweenStatementsSchema0
export type RuleOptions
= PaddingLineBetweenStatementsRuleOptions
export type MessageIds =
| 'unexpectedBlankLine'
| 'expectedBlankLine'
@@ -0,0 +1,101 @@
# Update a vendored installation
Use this procedure for an existing anti-slop installation. Local edits are owned policy, not drift to erase. An update is a reviewed merge, not a directory replacement.
## 1. Establish scope and protect local work
Locate the active generic and optional Effect entry points through lint configuration, then inspect their rules, helpers, tests, options, severities, overrides, and dependency versions. Read any `UPSTREAM.md` or equivalent provenance record and relevant Git history. Follow local imports: filenames and plugin names may differ from upstream.
Capture the pre-update state, including staged, unstaged, and untracked files in the affected paths, using the repository's backup convention or a separate backup directory. Keep this backup outside the merge destination. Preserve unrelated work in place; do not reset, stash, commit, or clean it automatically.
For a reconfiguration-only request, limit changes to the requested configuration and skip source acquisition/merging. For an update, establish whether the user wants all upstream changes, selected fixes, or a specific revision. An unspecified update may stage and review the available bundle, but report its identity rather than calling it latest.
Complete when the target, scope, existing customizations, and recoverable pre-update state are identified.
## 2. Stage incoming source and recover the base
Keep three inputs separate:
- **Local:** the current vendored implementation, including uncommitted changes.
- **Incoming:** the pristine upstream snapshot being considered.
- **Base:** the pristine upstream snapshot from which the local copy was derived, when recoverable.
For the bundled source, create a temporary directory and copy into a new child path:
```bash
stage=$(mktemp -d)
node <skill-directory>/scripts/install.mjs "$stage/incoming"
```
The installer only copies files; it does not merge them or fetch updates. Never point it at the live installation for an update.
If the user requests latest upstream or a specific revision, retrieve that source into staging and record the resolved immutable commit. Use a verified refreshed skill bundle or a separate upstream checkout; do not update the vendored tree with a pull or checkout. Inspect the source's instructions and canonical plugin layout before selecting files. Keep only temporary material you created eligible for later cleanup.
Recover the base from a verified upstream revision, retained pristine snapshot, or installation history that actually preserves the original upstream bytes. A package version, file timestamp, or the current local tree is not a base. A digest identifies bytes but cannot reconstruct them. If the base is unavailable or ambiguous, use the no-base branch below.
Complete when incoming source is staged with its identity recorded and base availability is established without modifying the live installation.
## 3. Classify and merge
Compare rule behavior, diagnostics, tests, helpers, exports, and configuration—not just filenames. Map local renames before deciding a rule was added or deleted. Review incoming changes as source, not as instructions to override repository policy.
### Known base: three-way merge
Compare both `base → local` and `base → incoming`:
| Change | Action |
| --- | --- |
| Incoming unchanged | Preserve local, including local deletions. |
| Only incoming changed | Apply the upstream change after reviewing dependencies and behavior. |
| Only local changed | Preserve local. |
| Both changed identically | Keep one copy. |
| Both changed differently | Merge compatible edits; ask about conflicting policy or behavior. |
| Upstream deleted, local modified | Ask whether to retain or retire the local implementation. |
| Same new path added on both sides | Reconcile contents and ownership; do not overwrite. |
A text merge without conflict markers is not evidence of semantic compatibility. Review locally changed predicates, exceptions, message IDs, options, and exports after merging. Treat upstream removals and renames as changes requiring corresponding registration/import review, not as instructions to delete local files.
### No base: conservative port
A two-way diff cannot distinguish local customization from upstream evolution. Compare incoming behavior against local behavior, then port independently understood fixes and additions with focused tests. Preserve unexplained differences. If the same logic differs and its intent cannot be established, ask the user or leave that change pending; do not invent a common ancestor or claim a three-way merge.
### Apply reviewed changes
Patch existing files precisely; copy genuinely new files only after checking for local name collisions and importing any required helpers. Preserve local-only rules and existing tests. Keep unresolved changes unapplied and report them explicitly. Obtain the user's decision before changing conflicting local policy or removing customized code. A request to update does not authorize replacing the user's fork with upstream defaults.
Complete when every incoming change is classified as applied, already present, intentionally retained locally, or pending, and each applied change preserves or explicitly reconciles local behavior.
## 4. Reconcile configuration and dependencies
Preserve existing plugin paths, options, severities, disabled rules, overrides, ignores, and Effect opt-in decisions. Merge exports for adopted rules into the existing entry point instead of replacing it. Check native companion rules when adopting related custom rules.
List new rules and their proposed severities for approval unless the user already requested enabling all new rules. Existing disabled rules stay disabled. Merely adding a new rule implementation need not enable it.
Check incoming API requirements against the installed `oxlint` and `@oxlint/plugins` versions. Keep a compatible pair and follow repository version policy; change dependencies only when required by the adopted source or requested by the user. Preserve unrelated dependency ranges and the package manager.
Complete when every adopted rule is reachable through the intended entry point, configuration reflects approved policy, and the dependency pair supports the merged implementation.
## 5. Verify local and incoming behavior
Run existing tests and add focused RuleTester cases for changed rule semantics, including regressions that preserve local customizations. Incoming tests are useful evidence, not replacements for local tests. When a skill bundle lacks upstream tests, retrieve relevant tests from the identified upstream revision or write focused cases for the adopted changes.
Exercise the registered plugin with representative accepted and rejected code. Run the repository's lint, typecheck, and required checks; for Vite+, run `vp check`. Vendored files may be excluded from application lint, so verify changed rules explicitly rather than treating an ignored directory as tested.
Report application findings separately from plugin/test failures. Application cleanup requires the user's authorization; retain rule severity and safety checks while reporting findings. If a required check is unavailable, name the blocker and mark verification incomplete.
Complete when adopted behavior and preserved local behavior have test evidence, checks have run, and every failure or verification gap is accounted for.
## 6. Record the merge and hand back ownership
Update `UPSTREAM.md` beside the vendored entry point, or the repository's existing provenance record, with:
- incoming source identity and the recoverable base used, or explicit unknown-base status;
- adopted changes and intentional local deviations;
- deferred/conflicting changes and the decisions still needed;
- dependency/configuration changes and verification results.
Advance the whole-installation baseline only when the complete incoming snapshot has been reconciled and remaining differences are recorded as intentional local changes. For a partial update, retain the prior baseline and record exactly which changes were adopted or remain pending; do not label the entire tree as matching the new revision. Without a recoverable base, keep that limitation explicit for the next update.
Review the final diff against the pre-update state. Report applied updates, preserved customizations, pending decisions, checks, and the backup location. Keep backups until the user accepts the result; remove only disposable staging material you created.
Complete when the user can distinguish upstream updates from local policy, recover the previous state, and identify what a future update must still reconcile.
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env node
import { cpSync, existsSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const skillRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const source = resolve(skillRoot, "assets/anti-slop");
const arguments_ = process.argv.slice(2);
const targetArgument = arguments_.find((argument) => !argument.startsWith("--"));
const target = resolve(process.cwd(), targetArgument ?? "tools/oxlint/anti-slop");
const force = arguments_.includes("--force");
if (existsSync(target) && !force) {
console.error(`Refusing to overwrite ${target}. Re-run with --force only after reviewing the existing files.`);
process.exit(1);
}
mkdirSync(dirname(target), { recursive: true });
cpSync(source, target, { recursive: true, force });
console.log(`Copied the anti-slop plugin to ${target}`);
console.log(`Configure Oxlint with: ${target}/index.ts`);
-116
View File
@@ -1,116 +0,0 @@
const COPYRIGHT_HEADER = `/*
Copyright %%CURRENT_YEAR%% New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE in the repository root for full details.
*/
`;
module.exports = {
plugins: ["matrix-org", "rxjs", "jsdoc"],
extends: [
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:matrix-org/typescript",
"prettier",
"plugin:rxjs/recommended",
"plugin:storybook/recommended",
],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
project: ["./tsconfig.json"],
},
env: {
browser: true,
node: true,
},
rules: {
"jsdoc/no-types": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/check-values": "error",
"jsdoc/check-param-names": "warn",
// "jsdoc/require-param": "warn",
"jsdoc/require-param-description": "warn",
"matrix-org/require-copyright-header": ["error", COPYRIGHT_HEADER],
"jsx-a11y/media-has-caption": "off",
"react/display-name": "error",
// Encourage proper usage of Promises:
"@typescript-eslint/no-floating-promises": "error",
"@typescript-eslint/no-misused-promises": "error",
"@typescript-eslint/promise-function-async": "error",
"@typescript-eslint/require-await": "error",
"@typescript-eslint/await-thenable": "error",
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk):
"@typescript-eslint/consistent-type-imports": [
"error",
{ fixStyle: "inline-type-imports" },
],
// To encourage good usage of RxJS:
"rxjs/no-exposed-subjects": "error",
"rxjs/finnish": ["error", { names: { "^this$": false } }],
"no-restricted-imports": [
"error",
{
paths: ["matrix-widget-api", "matrix-js-sdk"].flatMap((lib) =>
["src", "src/", "src/index", "lib", "lib/", "lib/index"]
.map((path) => `${lib}/${path}`)
.map((name) => ({ name, message: `Please use ${lib} instead` })),
),
patterns: [
...["matrix-widget-api"].map((lib) => ({
group: ["src", "src/", "src/**", "lib", "lib/", "lib/**"].map(
(path) => `${lib}/${path}`,
),
message: `Please use ${lib} instead`,
})),
// XXX: We use /lib in lots of places, so allow for now.
...["matrix-js-sdk"].map((lib) => ({
group: ["src", "src/", "src/**"].map((path) => `${lib}/${path}`),
message: `Please use ${lib} instead`,
})),
],
},
],
},
overrides: [
{
files: ["src/*/**"],
rules: {
// In application code we should use the js-sdk logger, never console directly.
"no-console": ["error"],
},
},
{
files: [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**",
],
rules: {
"jsdoc/no-types": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/check-values": "off",
"jsdoc/check-param-names": "off",
"jsdoc/require-param-description": "off",
},
},
{
files: ["playwright/**"],
rules: {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off",
},
},
],
settings: {
react: {
version: "detect",
},
},
};
+18 -11
View File
@@ -1,4 +1,11 @@
<!-- Thanks for submitting a PR! Please ensure the following requirements are met in order for us to review your PR -->
<!-- Thanks for submitting a PR! Please read CONTRIBUTING.md before you start. -->
> [!IMPORTANT]
> **Features and UI changes require a pre-approved issue.**
> Every PR must have a linked issue
> that a maintainer has reviewed and approved **before you started writing code**.
> PRs that don't meet this requirement will not be reviewed.
> See [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) for ElementCall decided for this approach.
## Content
@@ -6,34 +13,34 @@
## Motivation and context
<!-- Provide link to the corresponding issue if applicable or explain the context -->
<!-- Provide a link to the pre-approved issue, or explain the context for a bug fix -->
## Screenshots / GIFs
<!--
You can use a table like this to show screenshots comparison.
Uncomment this markdown table below and edit the last line `|||`:
|copy screenshot of before here|copy screenshot of after here|
You can use a table like this to show a before/after comparison.
Uncomment the markdown table below and fill in the last line:
|Before|After|
|-|-|
|||
-->
-->
## Tests
<!-- Explain how you tested your development -->
<!-- Explain how you tested your changes -->
- Step 1
- Step 2
- Step ...
-
## Checklist
- [ ] I have read through [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/livekit/CONTRIBUTING.md).
- [ ] Pull request includes screenshots or videos if containing UI changes
- [ ] Tests written for new code (and old code if feasible).
- [ ] A linked, pre-approved issue exists for this feature or UI change.
- [ ] I have read [CONTRIBUTING.md](https://github.com/element-hq/element-call/blob/main/CONTRIBUTING.md) in full.
- [ ] Pull request includes screenshots or videos for any UI changes.
- [ ] Tests written for new code (and existing touched code where feasible).
- [ ] Linter and other CI checks pass.
- [ ] I have licensed the changes to Element by completing the [Contributor License Agreement (CLA)](https://cla-assistant.io/element-hq/element-call)
+256
View File
@@ -0,0 +1,256 @@
- name: "A-1:1"
description: "Calls between two people"
color: "bfd4f2"
- name: "A-Big-Grid"
description: "The freedom layout system used for >12 participants"
color: "bfd4f2"
- name: "A-Developer-Experience"
description: "Workflow of developing: building, linting, debugging, profiling, etc."
color: "c5def5"
- name: "A-E2EE"
description: "End-to-end encryption"
color: "bfd4f2"
- name: "A-Embedded"
description: "Using the app embedded within other Matrix clients (as a widget)"
color: "bfd4f2"
- name: "A-Feedback-Reporting"
description: "Reporting process for bugs, debug logs (rageshakes), suggestions"
color: "bfd4f2"
- name: "A-Freedom"
description: "Freedom layout, where participants can be rearranged and resized"
color: "bfd4f2"
- name: "A-Handset"
description: "Audio playback through the earpiece of a phone. Also known as 'earpiece mode' or 'handset mode'."
color: "bfd4f2"
- name: "A-Huddle"
description: "Ad-hoc calls in a room notifying others"
color: "bfd4f2"
- name: "A-Lobby"
description: "The page before joining a call"
color: "bfd4f2"
- name: "A-Login"
color: "bfd4f2"
- name: "A-Matrix2.0"
description: "Issues relating to the Matrix 2.0 / MSC4143 work, such as sticky events and multi-sfu"
color: "bfd4f2"
- name: "A-Media-Devices"
color: "BFD4F2"
- name: "A-Media-Quality"
description: "Distortions or glitches in audio/video"
color: "bfd4f2"
- name: "A-Meeting"
description: "Scheduled call on the calendar"
color: "bfd4f2"
- name: "A-Mobile"
description: "Using the app on a mobile device"
color: "bfd4f2"
- name: "A-Moderation"
description: "Access to calls and powers within calls"
color: "bfd4f2"
- name: "A-Performance"
color: "bfd4f2"
- name: "A-Reactions"
color: "bfd4f2"
- name: "A-Registration"
color: "bfd4f2"
- name: "A-Screen-Sharing"
color: "bfd4f2"
- name: "A-SDK"
description: "SDK for building MatrixRTC + LiveKit widgets"
color: "c5def5"
- name: "A-Settings"
color: "bfd4f2"
- name: "A-SFU"
description: "Routing calls through a selective forwarding unit"
color: "bfd4f2"
- name: "A-Signaling"
description: "Call signaling"
color: "bfd4f2"
- name: "A-Simulcast"
description: "Automatic selection of variable video resolutions"
color: "bfd4f2"
- name: "A-SPA"
description: "Standalone application accessed via call links"
color: "bfd4f2"
- name: "A-Spatial-Audio"
description: "Directional audio based on where a speaker appears on screen"
color: "bfd4f2"
- name: "A-Speech-Enhancement"
description: "Techniques to enhance the intelligibility of speech in calls"
color: "c5def5"
- name: "A-Split-Grid"
description: "The freedom layout system used for ≤12 participants"
color: "bfd4f2"
- name: "A-Spotlight"
description: "Spotlight layout, where the active speaker is foregrounded"
color: "bfd4f2"
- name: "A-Telemetry-Posthog"
description: "Share opt in usage data for optimizing the app via posthog"
color: "bfd4f2"
- name: "A-Testing"
description: "Integration tests, unit tests, etc."
color: "bfd4f2"
- name: "A-Video-Rooms"
description: "Rooms reserved exclusively for calling"
color: "bfd4f2"
- name: "A-Walkie-Talkie"
description: "Walkie-talkie / PTT (push-to-talk) mode"
color: "bfd4f2"
- name: "A11y"
description: "Accessibility"
color: "4ADEC0"
- name: "backport-candidate"
description: "Something that is a candidate for backport to a particular release branch"
color: "0B8D85"
- name: "Claude-Review"
description: "Request code review from Claude"
color: "e57157"
- name: "customer-retainer"
color: "F44A5F"
- name: "dependencies"
description: "Pull requests that update a dependency file"
color: "0366d6"
- name: "development build"
description: "runs yarn build process in development mode"
color: "1d76db"
- name: "Discord"
description: "Use case familiar to Discord users"
color: "3670d2"
- name: "docker build"
description: "Creates a docker image for this PR"
color: "0e8a16"
- name: "EPIC"
color: "5319E7"
- name: "good first issue"
description: "Good for newcomers"
color: "7057ff"
- name: "Help Wanted"
description: "Community contributions are welcome!"
color: "159818"
- name: "I18n"
description: "Internationalisation"
color: "d4c5f9"
- name: "O-Frequent"
description: "Affects or can be seen by most users regularly or impacts most users' first experience"
color: "0052CC"
- name: "O-Occasional"
description: "Affects or can be seen by some users regularly or most users rarely"
color: "1D76DB"
- name: "O-Uncommon"
description: "Most users are unlikely to come across this or unexpected workflow"
color: "C5DEF5"
- name: "p1"
description: "Must fix/implement before this is usable as a product"
color: "D93F0B"
- name: "p2"
description: "Should fix/implement, but not at the expense of p1s"
color: "FBCA04"
- name: "p3"
description: "Could fix/implement when time allows"
color: "0E8A16"
- name: "PR-Breaking-Change"
description: "A Pull request that changes EC in a way that is incompatible to the previous version."
color: "D93F0B"
- name: "PR-Bug-Fix"
description: "Release note category. A PR that fixes a bug."
color: "C2E0C6"
- name: "PR-Developer-Experience"
description: "Release note category. A PR that does not change EC but improves working with the repository."
color: "C2E0C6"
- name: "PR-Documentation"
description: "Release note category. A PR that improves the documentation."
color: "C2E0C6"
- name: "PR-Feature"
description: "Release note category. A PR that introduces a new user facing feature."
color: "C2E0C6"
- name: "PR-Improvement"
description: "Release note category. A PR that improves EC's performance or stability."
color: "C2E0C6"
- name: "PR-Task"
description: "Release note category. A PR that is hidden from release note."
color: "C2E0C6"
- name: "Privacy"
color: "f41192"
- name: "Roadmap"
color: "57457E"
- name: "S-Critical"
description: "Prevents work, causes data loss and/or has no workaround"
color: "bd0026"
- name: "S-Major"
description: "Severely degrades major functionality or product features, with no satisfactory workaround"
color: "fc4e2a"
- name: "S-Minor"
description: "Impairs non-critical functionality or suitable workarounds exist"
color: "feb24c"
- name: "S-Tolerable"
description: "Low/no impact on users"
color: "ffeda0"
- name: "Security"
color: "b3e5fc"
- name: "storybook build"
description: "Build and deploy the storybook frontend to netlify."
color: "45cd61"
- name: "T-Defect"
description: "Something isn't working: bugs, crashes, hangs, vulnerabilities, or other reported problems"
color: "98e6ae"
- name: "T-Enhancement"
description: "New features, changes in functionality, performance boosts, user-facing improvements"
color: "98e6ae"
- name: "T-Other"
description: "Questions, user support, anything else"
color: "98e6ae"
- name: "T-Task"
description: "Refactoring, enabling or disabling functionality, other engineering tasks"
color: "98e6ae"
- name: "X-Blocked"
description: "Cannot be merged due to external dependencies"
color: "ff7979"
- name: "X-Cannot-Reproduce"
description: "Needs reproduction steps"
color: "ff7979"
- name: "X-Needs-Design"
description: "May require input from the design team"
color: "ff7979"
- name: "X-Needs-Info"
description: "This issue is blocked awaiting information from the reporter"
color: "ff7979"
- name: "X-Needs-Investigation"
color: "ff7979"
- name: "X-Needs-Product"
description: "More input needed from the Product team"
color: "ff7979"
- name: "X-Regression"
color: "ff7979"
- name: "X-Release-Blocker"
color: "ff7979"
- name: "X-Spec-Changes"
description: "May require spec changes"
color: "ff7979"
- name: "X-Won't-Fix"
description: "This will not be worked on"
color: "ff7979"
- name: "Z-Community-Testing"
description: "Issues found during the community testing sessions"
color: "efefef"
- name: "Z-Could"
color: "ededed"
- name: "Z-Design"
color: "ededed"
- name: "Z-Flaky-Test"
color: "aaaaaa"
- name: "Z-Media-Failure"
description: "Someone's audio or video isn't coming through"
color: "ededed"
- name: "Z-Must"
color: "ededed"
- name: "Z-Platform-Specific"
color: "ededed"
- name: "Z-Power-Users"
color: "ededed"
- name: "Z-ProductPolish"
color: "aaaaaa"
- name: "Z-Should"
color: "ededed"
- name: "Z-Splitbrain"
description: "Someone who should be on the call isn't showing up"
color: "ededed"
+4 -1
View File
@@ -44,7 +44,9 @@ jobs:
node-version-file: ".node-version"
- 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"
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
- name: Build Element Call
run: pnpm run build:"$PACKAGE":"$BUILD_MODE"
env:
@@ -62,5 +64,6 @@ jobs:
with:
name: build-output-${{ inputs.package }}
path: dist
if-no-files-found: error
# We'll only use this in a triggered job, then we're done with it
retention-days: 1
+9 -6
View File
@@ -6,7 +6,7 @@ on:
- opened
- labeled
push:
branches: [livekit, full-mesh]
branches: [main]
jobs:
build_full_element_call:
# Use the full package vite build
@@ -22,8 +22,8 @@ jobs:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
deploy_develop:
# Deploy livekit branch to call.element.dev after build completes
if: github.ref == 'refs/heads/livekit'
# Deploy main branch to call.element.dev after build completes
if: github.ref == 'refs/heads/main'
needs: build_full_element_call
runs-on: ubuntu-latest
steps:
@@ -43,8 +43,8 @@ jobs:
}
})
docker_for_develop:
# Build docker and publish docker for livekit branch after build completes
if: github.ref == 'refs/heads/livekit'
# Build docker and publish docker for main branch after build completes
if: github.ref == 'refs/heads/main'
needs: build_full_element_call
permissions:
contents: write
@@ -73,7 +73,10 @@ jobs:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
build_sdk_element_call:
# Use the embedded package vite build
# The 'sdk' package is the CallViewModel bundle that predates the sdk/
# package (build:sdk:production, sdk-target-based-on-call-view-model/), not
# the package itself. The name is shared with pr-deploy.yaml, which always
# runs from main, so it stays until the bundle's consumers have moved over.
uses: ./.github/workflows/build-element-call.yaml
with:
package: sdk
+90
View File
@@ -0,0 +1,90 @@
name: Claude
on:
pull_request:
types: [labeled]
permissions:
# The action exchanges the GitHub OIDC token for a short-lived Anthropic
# access token. GitHub API access goes through CLAUDE_GH_TOKEN instead.
id-token: write
jobs:
review:
name: Review
# Only run on labelling and skip forks.
if: github.event.label.name == 'Claude-Review' && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
with:
# The review looks at the individual commits, not just the combined diff
fetch-depth: 0
persist-credentials: false
- name: Review
uses: anthropics/claude-code-action@6fed3ca145920b639991cb756090506e1bcaf515 # v1.0.245
with:
anthropic_federation_rule_id: fdrl_01XQmWo4nka1ZTqZdsdeScyr
anthropic_organization_id: dfbc9a23-d636-4653-8970-035204ef60fa
anthropic_service_account_id: svac_018Ay5qaA7ZW8X7XsbdekqUM
anthropic_workspace_id: wrkspc_01Aw8LHjBS6fZysu9yqzYifW
# The token used here must grant read access to code, issues, and metadata as well as
# read and write access to pull requests.
github_token: ${{ secrets.CLAUDE_GH_TOKEN }}
use_sticky_comment: true
track_progress: true
prompt: |
Review this pull request under the following (and no further) angles:
1. Scope:
- Does the PR do one thing?
- Is the PR free of unrelated fixes and drive-by refactorings?
- If the PR is large, could it reasonably be split into smaller PRs
that still deliver value?
2. Commits:
- Is the work split into self-contained commits of reasonable size?
- Are the commits arranged in a sensible order?
- Have required refactorings been split out into separate commits?
- Is the history free of "wip", checkpoint and fixup-style commits
which only correct an earlier commit in the same PR?
3. Code:
- Are the code changes functionally correct?
- Do the changes introduce new security vulnerabilities or metadata
leaks?
- Is the new code performant enough?
- Don't check anything that is already validated via other GitHub
workflows like linting, formatting or tests passing.
Read the commits themselves with `git log` and `git diff` against
the base branch of the PR.
Drop any findings that are outside the above review angles or that don't
require resolution. Grade the severity of each finding as either "🟡 low",
"🟠 medium" or "🔴 high". Devise at least one possible resolution for each
finding.
Report the graded findings and resolutions from the "Code" angle as inline
comments. Add a footer "_-- Finding from automated review_" to each inline
comment.
Finally, report your overall result as PR comment. Start your message with
"# Automated review". Then add one section per review angle with:
- A verdict of "✅ PASS" or "❌ NEEDS WORK"
- A bullet list summarizing the remaining non-inlined graded findings and
resolutions (as nested bullets).
Be brief and concise in all your communication. Do not mention what you have
not covered and don't bring up issues that have already been fixed.
Use Markdown links for anything that can be navigated to on GitHub such
as files, commits, issues, pull requests, workflow runs, etc.
claude_args: |
--model claude-fable-5-1
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(git log:*),Bash(git diff:*),Bash(git show:*)"
--max-turns 50
+17 -6
View File
@@ -1,14 +1,22 @@
name: Lint, format & type check
on:
pull_request: {}
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
jobs:
prettier:
lint:
name: Lint, format & type check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -19,13 +27,16 @@ jobs:
node-version-file: ".node-version"
- 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"
- name: Prettier
run: "pnpm run prettier:check"
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Formatting
run: "pnpm run format:check"
- name: i18n
run: "pnpm run i18n:check"
- name: ESLint
run: "pnpm run lint:eslint"
- name: Lint
run: "pnpm run lint:oxlint"
- name: Type check
run: "pnpm run lint:types"
- name: Dead code analysis
@@ -9,7 +9,7 @@ on:
- opened
- labeled
push:
branches: [livekit]
branches: [main]
jobs:
versioning:
@@ -97,7 +97,7 @@ jobs:
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
- name: Upload
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -237,7 +237,6 @@ jobs:
with:
repository: element-hq/element-call-swift
path: element-call-swift
token: ${{ secrets.SWIFT_RELEASE_TOKEN }}
persist-credentials: false
- name: Copy files
@@ -268,9 +267,10 @@ jobs:
GITHUB_EVENT_RELEASE_HTML_URL: ${{ github.event.release.html_url }}
- name: Push
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
working-directory: element-call-swift
run: |
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags ${{ needs.versioning.outputs.DRY_RUN == 'true' && '--dry-run' || '' }}
git push "https://x-access-token:${SWIFT_RELEASE_TOKEN}@github.com/element-hq/element-call-swift.git" --tags
env:
SWIFT_RELEASE_TOKEN: ${{ secrets.SWIFT_RELEASE_TOKEN }}
@@ -297,7 +297,7 @@ jobs:
NEEDS_PUBLISH_IOS_OUTPUTS_ARTIFACT_VERSION: ${{ needs.publish_ios.outputs.ARTIFACT_VERSION }}
- name: Add release notes
if: ${{ needs.versioning.outputs.DRY_RUN == 'false' }}
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
append_body: true
body: |
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
- name: Create Checksum
run: find ${FILENAME_PREFIX} -type f -print0 | sort -z | xargs -0 sha256sum | tee ${FILENAME_PREFIX}.sha256
- name: Upload
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
files: |
${{ env.FILENAME_PREFIX }}.tar.gz
@@ -71,7 +71,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Add release note
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
append_body: true
body: |
+23
View File
@@ -0,0 +1,23 @@
name: Sync labels
on:
workflow_dispatch: {}
push:
branches:
- main
paths:
- .github/labels.yml
- .github/workflows/sync-labels.yml
permissions: {} # We use LABEL_SYNC_GITHUB_TOKEN instead
jobs:
sync-labels:
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@7f2f93fb9b52ece7a0998f60e64862aa203c1746
with:
LABELS: |
.github/labels.yml
DELETE: true
WET: true
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.LABEL_SYNC_GITHUB_TOKEN }}
+64 -9
View File
@@ -2,7 +2,26 @@ name: Test
on:
pull_request: {}
push:
branches: [livekit, full-mesh]
branches: [main]
workflow_call:
inputs:
matrix-js-sdk-sha:
type: string
required: false
description: The matrix-js-sdk commit to build against instead of the locked one
disable_coverage:
type: boolean
required: false
default: false
description: Skip generating and uploading coverage reports
disable_e2e_tests:
type: boolean
required: false
default: false
description: Skip the end-to-end tests
secrets:
CODECOV_TOKEN:
required: false
jobs:
vitest:
name: Run unit tests
@@ -11,6 +30,8 @@ jobs:
- name: Checkout code
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -21,11 +42,29 @@ jobs:
node-version-file: ".node-version"
- 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"
run: "scripts/layered.sh --ignore-pnpmfile"
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright binaries
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Vitest
run: "pnpm run test:coverage"
run: pnpm vitest --coverage.enabled="$ENABLE_COVERAGE"
env:
ENABLE_COVERAGE: ${{ !inputs.disable_coverage }}
- name: Upload to codecov
uses: codecov/codecov-action@75cd11691c0faa626561e295848008c8a7dddffe # v5
if: ${{ !inputs.disable_coverage }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
with:
@@ -33,11 +72,14 @@ jobs:
fail_ci_if_error: true
playwright:
name: Run end-to-end tests
if: ${{ !inputs.disable_e2e_tests }}
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
# Inside a called workflow, github.repository is the caller so we need to make sure to still check out the EC repo here.
repository: ${{ inputs.matrix-js-sdk-sha && 'element-hq/element-call' || github.repository }}
persist-credentials: false
- name: Enable Corepack
run: corepack enable
@@ -47,13 +89,26 @@ jobs:
node-version-file: ".node-version"
- 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
- name: Install Playwright Browsers
run: pnpm exec playwright install --with-deps
run: scripts/layered.sh --ignore-pnpmfile
env:
PR_HEAD_REPO_OWNER: ${{ github.event.pull_request.head.repo.owner.login }}
JS_SDK_GITHUB_BASE_REF: ${{ inputs.matrix-js-sdk-sha }}
- name: Get Playwright version
run: echo "PLAYWRIGHT_VERSION=$(pnpm list @playwright/test --depth=0 --json | jq -r '.[0].devDependencies["@playwright/test"].version')" >> $GITHUB_ENV
- name: Cache Playwright binaries
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
id: playwright-cache
with:
path: |
~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ env.PLAYWRIGHT_VERSION }}
- name: Install Playwright binaries
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm playwright install --with-deps
- name: Run backend components
run: |
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml pull
docker compose -f playwright-backend-docker-compose.yml -f playwright-backend-docker-compose.override.yml up -d
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml pull
docker compose -f docker-compose-dev.yml -f docker-compose-playwright.yml up -d
docker ps
- name: Run Playwright tests
env:
+8 -3
View File
@@ -40,8 +40,13 @@ jobs:
- name: Fix the owner of the downloaded files
run: "sudo chown runner:docker -R locales"
- name: Prettier
run: pnpm prettier:format
# Localazy doesn't write file contents in the same order that i18next-cli's
# extractor uses. We re-run the extractor after downloading to fix the order.
- name: i18n
run: pnpm i18n
- name: Formatting
run: pnpm format
- name: Create Pull Request
id: cpr
@@ -53,7 +58,7 @@ jobs:
title: Localazy Download
commit-message: Translations updates
labels: |
T-Task
PR-Task
- name: Enable automerge
run: gh pr merge --merge --auto "$PR_NUMBER"
+1 -1
View File
@@ -2,7 +2,7 @@ name: Upload translation files to Localazy
on:
push:
branches:
- livekit
- main
paths-ignore:
- ".github/**"
+3 -3
View File
@@ -2,7 +2,7 @@ name: GitHub Actions Security Analysis with zizmor 🌈
on:
push:
branches: ["livekit", "full-mesh"]
branches: ["main", "full-mesh"]
pull_request: {}
permissions: {}
@@ -15,9 +15,9 @@ jobs:
security-events: write # Required for upload-sarif (used by zizmor-action) to upload SARIF files.
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
+6
View File
@@ -10,6 +10,9 @@ public/config.json
backend/synapse_tmp/*
backend/synapse_tmp_othersite/*
/coverage
# Transient agent scratch files
/agent-workspace/
config.json
# Yarn
@@ -38,3 +41,6 @@ yarn-error.log
*storybook.log
storybook-static
# Nested checkout made by scripts/layered.sh
/matrix-js-sdk/
+14
View File
@@ -0,0 +1,14 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"printWidth": 80,
"sortPackageJson": false,
"ignorePatterns": [
"pnpm-lock.yaml",
"node_modules",
"dist",
".agents",
".claude",
"agent-workspace",
"tools/oxlint/anti-slop"
]
}
+202
View File
@@ -0,0 +1,202 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"ignorePatterns": [
".agent/**",
".agents/**",
".claude/**",
".codex/**",
".continue/**",
".cursor/**",
".gemini/**",
".opencode/**",
".pi/**",
".roo/**",
".windsurf/**",
"agent-workspace/**",
"tools/oxlint/anti-slop/**"
],
"plugins": [
"oxc",
"eslint",
"import",
"jsdoc",
"jsx-a11y",
"promise",
"react",
"typescript",
"unicorn",
"vitest"
],
"jsPlugins": [
"eslint-plugin-storybook",
"eslint-plugin-element-call",
{ "name": "anti-slop", "specifier": "./tools/oxlint/anti-slop/index.ts" }
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "eslint-plugin-rxjs"
],
"categories": {
"correctness": "error",
"perf": "error"
},
"options": {
"denyWarnings": true,
"typeAware": true
},
"env": {
"builtin": true
},
"rules": {
"element-call/copyright-header": [
"error",
"/*\nCopyright %%CURRENT_YEAR%% Element Creations Ltd.\n\nSPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial\nPlease see LICENSE in the repository root for full details.\n*/\n\n"
],
"element-call/no-observablescope-leak": "error",
"element-call/no-top-level-logger-get-child": "error",
"element-call/sdk-import-boundary": "error",
"oxc/no-accumulating-spread": "error",
"anti-slop/no-array-filter-map": "error",
"anti-slop/no-reduce-accumulator-copy": "error",
"anti-slop/no-chained-type-assertions": "error",
"anti-slop/no-conditional-empty-object-spread": "error",
"anti-slop/no-known-value-widening": "error",
"anti-slop/no-module-mocking": "error",
"anti-slop/no-object-parameters": "error",
"anti-slop/no-reflect-apply": "error",
"anti-slop/no-reflect-get": "error",
"anti-slop/no-runtime-typeof": "error",
"anti-slop/no-shape-in-symbol-names": "error",
"anti-slop/no-unknown-parameters": "error",
"anti-slop/no-unknown-returns": "error",
"anti-slop/no-unknown-type-aliases": "error",
"anti-slop/no-unsafe-dictionary-type": "error",
"anti-slop/no-widen-then-assert": "error",
"anti-slop/require-readable-spacing": "error",
"anti-slop/require-safety-comment-for-type-assertion": "error",
"jsdoc/empty-tags": "error",
"jsdoc/check-property-names": "error",
"jsdoc/require-param-description": "warn",
"react/display-name": "error",
// TODO: These React Compiler-based rules joined oxlint's correctness
// category in v1.81 and flag many pre-existing patterns, including hooks
// like useLatest/useInitial/useReactiveState that intentionally access
// refs during render. Re-evaluate them when adopting the React Compiler.
"react/immutability": "off",
"react/no-deriving-state-in-effects": "off",
"react/preserve-manual-memoization": "off",
"react/refs": "off",
"react/set-state-in-effect": "off",
"react/static-components": "off",
"react/use-memo": "off",
// TODO: Re-enable once oxlint supports lint rules that rely on TypeScript type-awareness.
// "rxjs/no-exposed-subjects": "error",
// "rxjs/finnish": [
// "error",
// {
// "names": {
// "^this$": false
// }
// }
// ],
"no-restricted-imports": [
"error",
{
"patterns": [
{
"group": [
"matrix-widget-api/src",
"matrix-widget-api/src/",
"matrix-widget-api/src/**",
"matrix-widget-api/lib",
"matrix-widget-api/lib/",
"matrix-widget-api/lib/**"
],
"message": "Please use matrix-widget-api instead"
},
{
"group": [
"matrix-js-sdk/src",
"matrix-js-sdk/src/",
"matrix-js-sdk/src/**",
"matrix-js-sdk/lib",
"matrix-js-sdk/lib/",
"matrix-js-sdk/lib/index"
],
"message": "Please use matrix-js-sdk instead"
}
]
}
],
"typescript/no-floating-promises": "error",
"typescript/no-misused-promises": "error",
"typescript/promise-function-async": "error",
"typescript/require-await": "error",
"typescript/await-thenable": "error",
// To help ensure that we get proper vite/rollup lazy loading (e.g. for matrix-js-sdk).
"typescript/consistent-type-imports": [
"error",
{
"fixStyle": "inline-type-imports"
}
],
// TODO: These had to be disabled in the eslint -> oxlint migration. Would be nice to
// enable them in future or at least document why we're disabling them.
"eslint/no-await-in-loop": "off",
"eslint/no-unused-vars": ["error", { "args": "none" }],
"import/default": "off",
"jsdoc/check-tag-names": "off",
"jsx-a11y/prefer-tag-over-role": "off",
"promise/no-callback-in-promise": "off",
"react/jsx-key": "off",
"react/jsx-no-constructed-context-values": "off",
"react/no-array-index-key": "off",
"react/no-children-prop": "off",
"react/no-object-type-as-default-prop": "off",
"typescript/no-misused-spread": "off",
"typescript/no-useless-default-assignment": "off",
"typescript/restrict-template-expressions": "off",
"typescript/unbound-method": "off",
"vitest/expect-expect": "off",
"vitest/no-conditional-expect": "off",
"vitest/no-disabled-tests": "off",
"vitest/require-mock-type-parameters": "off",
"vitest/require-to-throw-message": "off"
},
"overrides": [
{
"files": ["src/*/**"],
"rules": {
// In application code we should use the js-sdk logger, never console directly.
"no-console": "error"
}
},
{
"files": [
"**/*.test.ts",
"**/*.test.tsx",
"**/test.ts",
"**/test.tsx",
"**/test-**"
],
"rules": {
// Tests often initialize an ObservableScope in an outer scope in
// beforeEach, which is not actually a problem
"element-call/no-observablescope-leak": "off",
"jsdoc/empty-tags": "off",
"jsdoc/check-property-names": "off",
"jsdoc/require-param-description": "off",
"jsx-a11y/media-has-caption": "off"
// TODO: Enable once oxlint supports them.
// "jsdoc/check-values": "off",
// "jsdoc/check-param-names": "off",
// "jsdoc/no-types": "off",
}
},
{
"files": ["playwright/**"],
"rules": {
// Playwright as a `use` function that has nothing to do with React hooks.
"react-hooks/rules-of-hooks": "off"
}
}
]
}
-3
View File
@@ -1,3 +0,0 @@
pnpm-lock.yaml
node_modules
dist
-1
View File
@@ -1 +0,0 @@
{}
+22 -1
View File
@@ -9,7 +9,28 @@ import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs"],
addons: ["@storybook/addon-docs", "@storybook/addon-vitest"],
framework: "@storybook/react-vite",
// THIS IS IMPORTANT
// vitest runs without Vite's normal dependency optimization, so we need to manually include the polyfills for the stories to work.
// otherwise we will get: new dependencies optimized: ...
// and
// ```
// [vitest] Vite unexpectedly reloaded a test. This may cause tests to fail, lead to flaky behaviour or duplicated test runs.
// For a stable experience, please add mentioned dependencies to your config's `optimizeDeps.include` field manually.
// ```
// which breaks the storybook ci on the first and only run.
viteFinal(config) {
config.optimizeDeps = {
...config.optimizeDeps,
include: [
...(config.optimizeDeps?.include ?? []),
"vite-plugin-node-polyfills/shims/buffer",
"vite-plugin-node-polyfills/shims/global",
"vite-plugin-node-polyfills/shims/process",
],
};
return config;
},
};
export default config;
+4 -2
View File
@@ -7,14 +7,16 @@ Please see LICENSE in the repository root for full details.
import type { Preview } from "@storybook/react-vite";
import { TooltipProvider } from "@vector-im/compound-web";
import i18n from "i18next";
import { logger } from "matrix-js-sdk/lib/logger";
import EN from "../locales/en/app.json";
import { initReactI18next } from "react-i18next";
import { i18n } from "../src/utils/i18n";
import "../src/index.css";
// Bare-minimum i18n config
// Bare-minimum i18n config.
// Unlike the app, stories register the instance as react-i18next's default
// rather than wrapping every story in an <I18nextProvider>.
i18n
.use(initReactI18next)
.init({
+56
View File
@@ -0,0 +1,56 @@
# AGENTS.md — Element Call
MatrixRTC (MSC4143) + LiveKit video calling, shipped standalone, as a widget in
Element Web and Element X, as embedded packages, and as a React component in a
host's page. It is the MatrixRTC reference implementation.
## Non-negotiables
- Features and UI changes need a pre-approved issue. No issue, no review.
- Exactly one `PR-*` label per PR. CI enforces it; it drives the changelog.
- New behaviour ships with unit tests, a story if it renders, and an e2e spec if it
is user-facing. All three.
- Reuse an existing component. If you genuinely cannot, say so in the PR body and
name what you rejected — never add a new one silently.
- Nothing reads the page. No `window.location`, `document.body`, `window.inner*`,
`@media`, global `i18next` or the `widget` global — take it from a provider.
- The change works standalone, as a widget and as a component. Say what you checked.
- Every gate below is green before you push.
- Hand off after the first implementation, before the quality pass. Then commit on
the user's word where the setup allows it, or hand them the message.
## Read before you
| … | … |
| -------------------------------- | ---------------------------------------------------------- |
| start a task of any size | [docs/agents/workflow.md](docs/agents/workflow.md) |
| touch call logic or a view model | [docs/agents/architecture.md](docs/agents/architecture.md) |
| write any code | [docs/agents/code-style.md](docs/agents/code-style.md) |
| write a test, story or e2e spec | [docs/agents/testing.md](docs/agents/testing.md) |
| open a PR | [CONTRIBUTING.md](CONTRIBUTING.md) |
| implement from a feature spec | [FEATURES_SPEC/AGENTS.md](FEATURES_SPEC/AGENTS.md) |
## Gates
```sh
pnpm lint # tsc, oxlint, knip, component externals
pnpm format # oxfmt
pnpm test # vitest: unit and storybook projects
pnpm i18n:check
```
`knip` fails on dead code; a file deliberately inert ahead of its consumer goes in
`knip.ts` `ignoreFiles` with a reason.
## A good PR
- One slice, under ~400 changed lines, green on its own, linked to its issue.
- View model + marble tests, thin view, a story per state, an e2e spec.
- Any new shared component called out explicitly, with why nothing existing fit.
- Template filled for real: what, why, before/after screenshots, repro steps.
- Comments only where the code can't give the reason — see
[code-style.md](docs/agents/code-style.md#comments).
- Anything that redraws continuously — an animation frame, a stream — says what it
costs, or better, counts the redraws in a test.
- Branch `<handle>/<topic>`. Plain imperative commit subjects, no prefixes.
- Once review starts, fix forward. Never force-push a regeneration over a review.
+68
View File
@@ -1,3 +1,71 @@
# Contributing code to Element
Element follows the same pattern as the [matrix-js-sdk](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md).
# Contributing to Element Call
Element Call is a native Matrix video conferencing application built on
[MatrixRTC (MSC4143)](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)
and [LiveKit](https://livekit.io/). It runs in multiple deployment contexts — as a
standalone web app and as a widget embedded in Element Web, Element X iOS, and
Element X Android. It is also the primary R&D foundation for MatrixRTC, which means
its architecture, maintainability, and flexibility are held to a high standard.
We welcome contributions from the community. This document explains how to
contribute effectively so that both you and the maintainers get the best outcome.
## Issue First Policy
> [!IMPORTANT]
> Before writing a single line of code for a new feature or UI change, you **must**
> open an issue and have the approach agreed with the maintainers.
>
> **We will not review or merge feature or UI pull requests that arrive without a
> corresponding, pre-approved issue.**
This is not gatekeeping — it's how we prevent wasted effort on both sides. Element
Call must work correctly across multiple deployment contexts and meet specific product
and design requirements. It is also a fast-moving codebase that underpins ongoing
MatrixRTC development. A PR that looks reasonable in isolation can easily conflict
with in-progress work, planned architecture changes, or design decisions that haven't
been publicly documented yet.
The issue is where we resolve all of that **before** anyone writes code.
**Bug fixes** are no exception — most confirmed bugs should already have an issue anyways, existing issues that are marked as bugs have an implicit maintainer approval. If the solution for the bug is controversial it is highly recommended to discuss the approach in the issue before opening a PR.
## Contribution Workflow
1. **Open an issue** using the [Enhancement request](https://github.com/element-hq/element-call/issues/new?template=enhancement.yml) template.
2. **Wait for feedback.** A maintainer will comment on the issue **within two weeks**. The use case and approach will get dicussed.
This may involve questions, suggestions, or a request to adjust scope.
This also allows to bring design and product into the loop before code gets created.
3. **Get a green light.** Wait for explicit approval from a maintainer before starting
implementation.
4. **Implement.** Write the code against the agreed approach.
5. **Open a PR.** Link to the issue in your PR description and satisfy the checklist
in the PR template.
## Code Quality
Element Call moves fast and the codebase must stay clean and maintainable.
- **Take responsibility for AI-generated code.** AI tools can be a useful aid, but we expect all the generated code to be understood and reasoned about by the contributor. Questions by the maintainers should be answered without just forwarding them to AI. The maintainers also have access to AI tools. If your contribution is just transporting messages between LLM <-> maintaines all our time is better used if the maintainers decide to interact with AI for this specific problem by themselves.
- **Think across deployment contexts.** Changes must work correctly in both standalone
and widget modes. Consider how your change interacts with Element Web, Element X
iOS, and Element X Android.
- **Write tests.** New functionality should be covered by tests. Where it is feasible,
existing uncovered code touched by your PR should also gain tests.
## Contributor License Agreement
All contributors must sign the
[Element Contributor License Agreement](https://cla-assistant.io/element-hq/element-call)
before their contribution can be merged. The CLA assistant bot will prompt you
automatically when you open a PR.
## Getting Help
The best place to ask questions about Element Call development is the MatrixRTC room:
**[#matrixRtc:matrix.org](https://matrix.to/#/#matrixrtc:matrix.org)**
+232
View File
@@ -0,0 +1,232 @@
# Merging PR #4331 onto the SDK / Element Call split
How [element-hq/element-call#4331](https://github.com/element-hq/element-call/pull/4331)
("LiveKit updates": MSC4195 client-server API tokens, `url` transports, delegation
through the homeserver, a transport dev override) lands on `toger5/use-sdk`, where
the MatrixRTC logic lives in `sdk/` and Element Call consumes it through
`sdk/src/api.ts`. The PR was written against `main`, where all of this still sat
in `src/state/CallViewModel/`. This document goes through the PR commit by commit,
says whether each one lands in `sdk/` or `src/`, and how; the section
[Changes to `api.ts`](#changes-to-apits) covers the public interface in full. The
end state: this branch behaves exactly as the PR does, with the split and the
`api.ts` seam intact.
The PR's branch is checked out at `agent-workspace/pr4331-merge/worktree` for
comparison. The `matrix-js-sdk` dependency is pinned to the same `develop`
commit the PR uses (`4df2f2bf`, 43.0.0), which brings `UnstableLivekitTransport`,
`CallMembership.backendIdentities`, `_unstable_getLivekitToken` and
`_unstable_delegateDelayedLeave`. That bump also renamed
`MatrixRTCSession.leaveRoomSession` to `leave`, which the SDK now calls.
## Where each commit lands
| # | Commit | Lands in | How |
| --- | ------------------------------------------------------------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | Remove stray pause and improve logging for hung e2e tests | e2e | Applied as is: `playwright.config.ts`, `playwright/widget/federated-call.test.ts`. |
| 2 | Fix typo in the name of the createJWTToken Playwright fixture | e2e | Applied as is. |
| 3 | Advertise the loopback address from the dev LiveKit SFUs | backend | Applied as is: `backend/dev_livekit*.yaml`. |
| 4 | Match LiveKit participants against all backend identities | sdk | `RTCMember.backendIdentities` (new, see api.ts). `LivekitBackend.mediaFor` matches `member.backendIdentities.includes(p.identity)`; `RTCParticipation.data$` maps a packet's sender the same way. `mockRtcMembership` in both `sdk/src/utils/test.ts` and `src/utils/test.ts` takes the new 4-argument constructor and a `backendIdentities` override. |
| 5 | Model ConnectionState as a discriminated union | sdk | `Connection.ts` state is `{state: "initialized" \| "authenticating" \| "authenticated" (+ `livekitState`) \| "error" (+ `error`) \| "stopped"}`. Internal to the LiveKit backend: `connectionState.ts` maps it onto the backend-neutral `MediaConnectionState`, so nothing above the backend changes. `DataChannel.ts` and `ConnectionFactory.ts` follow. |
| 6 | Show the whole transport in debug UI and logs | sdk + src | SDK log lines use `JSON.stringify(transport)`. Element Call: `focusUrl$: Behavior<string>` on the member media view models becomes `transport$: Behavior<Transport>` (`TransportMetadata.raw`), carried through `GridTile`, `SpotlightTile`, `MediaView` to `RTCConnectionStats`, which prints the JSON. The old code showed `extractDomain(transport.id)`, which on this branch was already a JSON string, so this also fixes a latent bug. |
| 7 | Group livekitRoomItems$ by connection | n/a | `livekitRoomItems$` and `LivekitRoomAudioRenderer` do not exist on this branch: audio renders from `tracks$` (`MemberAudioRenderer`). Nothing to port. |
| 8 | Move SFU authentication into livekit/auth and choose the endpoint by role | sdk | `sdk/src/media-backend/livekit/auth/{index,openID,csApi,types}.ts` replace `openIDSFU.ts`. Publisher → legacy `/sfu/get`; subscriber → `/get_token`. `slotId` replaces the hard-coded `m.call#ROOM`: `MediaBackendContext.slotId`, taken from the js-sdk session in `RTCParticipation`. The `tokenEndpoint` option of `createLivekitBackend` and the derivation from `MatrixRTCMode` are gone. |
| 9 | Let the publishing Connection authenticate itself | sdk | Every `Connection` has a `role` and fetches its own token; `existingSFUConfig` is gone. `prepareLocalTransport` no longer fetches a token itself: it starts the publisher connection (through the connection map) and resolves once that connection reports `authenticated`, rejecting with its error otherwise. Since `LocalMembership` joins only after `preparedTransport$` resolves, the join waits for the publisher's token exactly as the PR's `authenticated$` gate does. |
| 10 | Reduce ConnectionManager to a ConnectionMap | sdk | `ConnectionMap.ts` replaces `ConnectionManager.ts`; `createConnectionMap$` takes a resolved local transport, and `LivekitBackend` wraps it in `mapScoped` over the local transport, as the PR's `CallViewModel` did. `LocalMedia.ts`, `DataChannel.ts` take `connectionMap$`. |
| 11 | Use the unstable LiveKit transport type | sdk + src | `UnstableLivekitTransport` / `isUnstableLivekitTransport` throughout the backend; `src/settings/settings.ts`, `DeveloperSettingsTab`, test helpers. Transport discovery in `sdk/src/matrixrtc/` is now backend neutral (filters by `type`, see commit 15). |
| 12 | Identify transports by server name as well as URLs | sdk | `TransportLocator {transport, serverName}` in `auth/types.ts`; `ConnectionMap.keyFor` keys on `(url, livekit_service_url, serverName)`. The server name is derived from the user id (`serverNameOf`) for remote members and from our own membership identity for the local transport, so neither `api.ts` nor the backend options need it. |
| 13 | Pin matrix-js-sdk to the member_id branch | superseded | By commit 19; the lockfile pins the PR's final `develop` commit. |
| 14 | Get SFU tokens and delegate delayed leaves through the MSC4195 CS API | sdk | `auth/csApi.ts` (`_unstable_getLivekitToken`, `_unstable_delegateDelayedLeave`), `auth/index.ts` facade with the fallback rules. `LivekitBackend.delegateDelayedLeave` goes through the facade; the delegation probe asks only the service the publisher flow uses. `LivekitBackendOptions.client` picks the two new client methods. |
| 15 | Let developers override the whole transport | sdk + src | `RTCParticipationOptions.transport` / `fallbackTransport` take a `Transport` instead of `transportUrl` / `fallbackTransportUrl` (see api.ts). Element Call: setting `custom-transport` (JSON) replaces `custom-livekit-url`; `DeveloperSettingsTab` edits JSON and validates through `authenticateWithTransport`; `InCallView` turns `config.livekit.livekit_service_url` into the fallback transport. Locale keys `developer_mode.custom_transport.*`. |
| 16 | Deprecate the livekit.livekit_service_url config option | src | Applied as is in `ConfigOptions.ts`. |
| 17 | Cover url transports end to end | e2e + backend | `playwright/url-transport.spec.ts`, `backend/app-service*.yaml`, `docker-compose-dev.yml` (lk-jwt-service 0.8.0) copied as is. |
| 18 | Retain compatibility in compatibility mode | superseded | Reverted by commit 23 inside the PR; only its e2e spec edits survive, in the final spec file. |
| 19 | Switch JS SDK back to develop | deps | `pnpm-lock.yaml` pins `4df2f2bf` and `matrix-widget-api` 1.20.0; `package.json` keeps `#develop`. |
| 20 | Fall back to get_token as subscriber on widget API errors | sdk | `isCsApiUnavailable` in `auth/index.ts`; `doNetworkOperationWithRetry(op, shouldRetry)` in `sdk/src/utils/network.ts` with `isNetworkError` in `csApi.ts`. |
| 21 | Share a connection between homeservers using the same JWT service | sdk | `keyFor` ignores the server name for transports without a `url`; `removeDuplicateTransports` keeps the first locator so the local one wins. |
| 22 | Avoid null in transport dev setting | src | `transportText()` in `DeveloperSettingsTab.tsx`. |
| 23 | Make the auth choice mode independent again | sdk | `chooseAuthFlow(transport, role)` has no mode parameter; `config.ts` documents that `MatrixRTCMode` decides how the membership is written, not how the transport is authenticated. |
| 24 | Cover identity matching and delegation across transport shapes | sdk + e2e | Unit tests in `LivekitBackend.test.ts` (see below); `playwright/url-transport.spec.ts` final version. |
| 25 | Cover the subscriber token path, widget fallback and server names | sdk + e2e | `auth/openID.test.ts` subscriber cases; `playwright/widget/url-transport.test.ts`; the server-name test lives in `LivekitBackend.test.ts` ("connects to a remote member's transport on the member's homeserver"). |
## Changes to `api.ts`
Three changes to the public SDK interface, all forced by the PR's behaviour.
### `RTCMember.backendIdentities` (new)
```ts
/** The membership's `rtcBackendIdentity`, `${userId}:${deviceId}`: a key and a debug label. */
rtcBackendIdentity: string;
/**
* Every identity the member may publish under on its transport: the
* participant identity the SFU sees, and the sender of its data packets.
* One per authentication flow the member's transport allows, so a transport
* with both a `url` and a `livekit_service_url` lists the hashed and the
* legacy identity, and the backend matches whichever shows up.
*/
backendIdentities: readonly string[];
```
Commit 4 of the PR. A member on a transport that carries both a `url` and a
`livekit_service_url` may have authenticated through either flow and so has
two candidate identities; the js-sdk now lists them on the membership. The
backend matches media and data packets against the list.
`rtcBackendIdentity` stays as the stable per-membership key Element Call uses
for tile ids and the debug overlay, and its doc says that it is no longer
what the backend matches on. Read off the membership, like the single identity
before it; the backend will derive both once the identity scheme is its own.
### `RTCParticipationOptions.transport` / `fallbackTransport` replace the url options
```ts
/**
* Use this transport instead of asking the homeserver. Any shape the
* backend serves: for LiveKit a `url`, a `livekit_service_url` or both.
*/
transport?: Transport;
/** Use this transport when the homeserver advertises none of the backend's type. */
fallbackTransport?: Transport;
```
Commit 15 of the PR. The developer override has to carry a whole transport so
that a `url`-only or combined shape can be tested against a homeserver that
does not advertise one yet; a URL cannot say which shape it is. This also
removes the one piece of LiveKit knowledge that sat outside the backend:
`LocalTransport.ts` and `TransportAutoDiscovery.ts` no longer build a
`{type: "livekit", livekit_service_url}` object and filter by `transportType`
only, which `LocalTransport.ts` had marked as the moment to do this. The
host turns its config URL into a transport (`InCallView.rtcParticipationOptions`).
### `TransportMetadata` is unchanged, with one documented limit
`TransportMetadata.id` is still the raw transport serialised with sorted keys.
The LiveKit backend now keys its connections on the transport plus the server
name where the transport has a `url` (commits 12 and 21), so two homeservers
advertising the same `url` get two connections but one `TransportMetadata`.
`connectedTransports$` deduplicates, and `resolved$` reports the first
connection's resolution. This is diagnostics only; the media and data paths
go through the backend's own keys. Extending `TransportMetadata` with the
server name was left out to keep the type transport neutral; a developer panel
that needs it can be given a `serverName` on `BackendConnection` later.
### Related, outside `api.ts`
- `media-backend/api.ts`: `MediaBackendContext.slotId` (commit 8). The
client-server API issues a token for a slot, so the backend needs it;
`RTCParticipation` reads it from the js-sdk session.
- `sdk/index.ts`: `authenticateWithTransport` is now the auth facade's
`getSFUConfig({client, membership, transport, serverName, roomId, slotId, role, logger})`
(object parameter, whole transport, server name); `AuthenticateWithTransportParams`
is exported for it; `TokenEndpoint` is gone.
- `createLivekitBackend` options: `tokenEndpoint` is gone; `client` now picks
`_unstable_getLivekitToken` and `_unstable_delegateDelayedLeave` as well.
- `config.ts`: the `MatrixRTCMode` doc no longer claims the mode picks the
JWT endpoint (commit 23).
## Behaviour, side by side
| Behaviour | PR on `main` | This branch |
| -------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Endpoint choice | `chooseAuthFlow` in `src/livekit/auth` | Same function, `sdk/src/media-backend/livekit/auth/index.ts` |
| Join waits for the publisher's token | `authenticated$` gate in `LocalMember.ts` | `prepareLocalTransport` resolves on the publisher connection's `authenticated` state; `LocalMembership` joins after it |
| Delegation probe | The flow's service only, in `LocalMember.joinParams$` | The flow's service only, in `prepareLocalTransport` |
| Delegation handover | `delegateDelayedLeave` facade from `LocalMember` | Same facade from `LivekitBackend.delegateDelayedLeave`, with the delay timeout from the session timings instead of `Config` |
| Transport keys and connection sharing | `ConnectionMap.keyFor` on `TransportLocator` | Same, moved |
| Server names | `membershipsAndTransports$` from the user id; `getLocalTransport` from `client.getDomain()` | `serverNameOf(userId)` for members; our own membership identity's user id for the local transport |
| Identity matching | `membership.backendIdentities.includes(p.identity)` | `member.backendIdentities.includes(p.identity)` in `mediaFor` and for data packets |
| Dev transport override | `customTransport` setting → `getLocalTransport` | `customTransport` setting → `RTCParticipationOptions.transport` |
| Config fallback | `RtcTransportAutoDiscovery.tryConfigTransport` | Host builds the transport, `RTCParticipationOptions.fallbackTransport` |
| Error for a `url` host without MSC4195 | `NoMatrix2AuthorizationService` from the publisher connection | Same error, surfaced through `prepareLocalTransport` → `state$ failed` → `fromMatrixRTCError` |
One deliberate difference: on `main` the publisher connection fetches its token
on every (re)creation and nothing else does; here it is the same, since the
up-front token fetch in `prepareLocalTransport` is gone. There is no second
token request per join.
## Where the PR's new tests went
| PR test | Here |
| ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/livekit/auth/index.test.ts` | `sdk/src/media-backend/livekit/auth/index.test.ts`. Rewritten on the network layer (fetch-mock for the JWT service, mocked client methods) because a `vi.mock` of the sibling `openID` module does not reach the facade's own import under `sdk/`. Same cases, plus a table for `chooseAuthFlow`. |
| `src/livekit/auth/openID.test.ts` | `sdk/src/media-backend/livekit/auth/openID.test.ts`, replacing `openIDSFU.test.ts` |
| `ConnectionMap.test.ts` (incl. commit 21's sharing tests) | `sdk/src/media-backend/livekit/ConnectionMap.test.ts`, replacing `ConnectionManager.test.ts`; plus a role-tagging test |
| `Connection.test.ts` changes | `sdk/src/media-backend/livekit/Connection.test.ts`; plus a publisher-through-the-JWT-service case |
| `LocalMember.test.ts` "joins session with %s delegation support" (by transport shape) | `LivekitBackend.test.ts` "prepares the local transport and asks %s about delegation", "waits for the publisher connection to hold its token before the join", "fails the preparation with the publisher connection's error", "delegates the leave through the flow that authenticated the publisher" |
| `MatrixLivekitMembers.test.ts` two-candidate-identity tests | `LivekitBackend.test.ts` › `mediaFor` |
| `SessionBehaviors.test.ts` server-name test | `LivekitBackend.test.ts` "connects to a remote member's transport on the member's homeserver" |
| `LocalTransport.test.ts` / `RtcTransportAutoDiscovery.test.ts` changes | `sdk/src/matrixrtc/TransportAutoDiscovery.test.ts` (fallback is a transport) |
| `DeveloperSettingsTab.test.tsx` custom transport cases | `src/settings/DeveloperSettingsTab.test.tsx`, snapshot updated for the label |
| `DataChannel`, `LivekitBackend` registry tests | Adapted to the state union and `TransportLocator` |
| `playwright/url-transport.spec.ts`, `playwright/widget/url-transport.test.ts` | Copied as is: they drive the app, which behaves the same |
The PR's `CallViewModel.test.ts`, `CallViewModelTestUtils.ts`, `LocalMember.test.ts`
(the rest) and `MatrixAudioRenderer.test.tsx` edits have no counterpart: those
modules were replaced by the SDK before this merge.
## One fix the e2e run forced
The bumped js-sdk sets `rtcBackendIdentity` to the legacy `${userId}:${deviceId}`
identity for every membership (it is deprecated there; the hashed one now only
appears in `backendIdentities`). On this branch the call view model keyed its
tiles by `[dup, mediaId, rtcBackendIdentity]`, so a device that rejoined after an
improper leave, which has two memberships until the stale one expires, produced
duplicate keys and crashed the host's page with `UNKNOWN_ERROR`
(`spa-call-sticky.spec.ts`, "rejoin after improper leave"). The key is now the
membership's own `memberId`, and `MemberAudioRenderer` and the SDK harness key
by it too. The PR on `main` keyed by the participant object as well and did not
hit this.
## Coverage added on the package branch, and one bug it found
The PR's tests mostly covered the auth layer; the SDK modules under them had
little or none. Added on the package branch, which this branch is rebased onto:
| Module | Test |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `media-backend/livekit/Publisher.ts` | `Publisher.test.ts`: the cases from `main`'s `localMember/Publisher.test.ts` on a real `LocalParticipant` with faked device and signalling calls, plus publish, unpublish and destroy |
| `media-backend/livekit/LocalMedia.ts` | `LocalMedia.test.ts`: on a fake connection map; publish before and after the connection, republish on a new connection, mutes remembered, first publish error, output device |
| `RTCParticipation.ts` | `RTCParticipation.test.ts`: on a fake media backend; join options, state machine, publishing only while joined, delegation, member ids per mode, key replay, data sender check |
| `matrixrtc/LocalTransport.ts` | `LocalTransport.test.ts`: override, homeserver, fallback, none |
| `matrixrtc/joinJsSdkSession.ts` | `joinJsSdkSession.test.ts`: the js-sdk join options, the retry budget, the intent and sticky events |
| `media-backend/livekit/publishOptions.ts` | `publishOptions.test.ts` |
| `media-backend/livekit/ConnectionFactory.ts` | `ConnectionFactory.test.ts`: the room options from the requests and media quality (the cases from `main`'s `ECConnectionFactory.test.ts`), `resolved$` |
| `LivekitBackend.ts` › `mediaFor` | A member on another transport, a member changing transport, a member without one (from `main`'s `MatrixLivekitMembers.test.ts`) |
`ConnectionFactory.test.ts` reads the options off the real LiveKit room the
factory builds instead of mocking `livekit-client`: a `vi.mock` of the package
reached the factory's import on the package branch but not on this one.
The `LocalMedia` tests found a race in `ObservableScope.reconcile`: the loop was
marked as running until the promise of its last run had settled, a few
microtasks after it had last checked for new values, so a value arriving in
that window (a connection appearing at just that moment) was never processed.
Fixed in `[SDK] Keep a value that reaches reconcile as its loop winds down`,
with a test that drives the window microtask by microtask. Element Call's
`src/state/ObservableScope.ts` re-exports the SDK's class, so the fix applies
to the app as well.
## Checked
- `pnpm lint` (tsc, oxlint, knip, externals), `pnpm format:check`, `pnpm i18n:check`.
- `pnpm test`: 119 unit files, 950 tests; 4 storybook files, 45 tests (after the coverage above).
- Playwright on Chromium against the local Playwright backend, one worker:
- `playwright/url-transport.spec.ts`: 10 of 10 pass, including delegation
through the homeserver and the JWT service, the subscriber fallback, the
publisher error and the federated token.
- `playwright/widget/url-transport.test.ts`: passes.
- The rest of `playwright/*.spec.ts`, `playwright/component`, `playwright/sdk`:
48 pass, 1 skipped, 1 fails (`reconnect.spec.ts`, which fails on `main`
locally too because the local JWT service is slower than the spec's wait).
- `playwright/widget`: 7 of 10 pass, including `federated-call`. `huddle-call`
fails before any call code runs (the Element Web develop image's room list no
longer matches the helper's "Open room" selector), and `screen-share` looks
for `video[data-lk-source="screen_share"]`, an attribute the LiveKit React
components set and the SDK's plain `<video>` elements do not: a failure of this
branch that predates the PR.
- Local environment work needed for that: the othersite JWT service and nginx
containers had to be recreated from the compose file, two stale containers held
port 16080, and the widget helper's hard-coded `https://localhost:3000` was
pointed at 3100 for the run only, since port 3000 is taken on this machine.
- Standalone, widget and component all go through `InCallView.rtcParticipationOptions`
and the SDK, so the change is the same in all three modes; the widget-specific
fallback (a host without MSC4533) is in `isCsApiUnavailable`.
+189 -43
View File
@@ -34,15 +34,16 @@ You can find the latest development version continuously deployed to
## ✨ Key Features
✅ **Decentralized & Federated** – No central authority; works across Matrix
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
✅ **Standalone & Widget Mode** – Use as an independent app or embed in Matrix
clients.
✅ **WebRTC-based** – No additional software required.
homeservers.
✅ **End-to-End Encrypted** – Secure and private calls.
✅ **Standalone, Widget & Component Mode** – Use as an independent app, embed
in Matrix clients as a widget, or (experimentally) mount it as a React component
inside your own application.
✅ **WebRTC-based** – No additional software required.
✅ **Scalable with LiveKit** – Supports large meetings via SFU
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
([MSC4195: MatrixRTC using LiveKit backend](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)).
✅ **Raise Hand** – Participants can signal when they want to speak, helping to
organize the flow of the meeting.
organize the flow of the meeting.
✅ **Emoji Reactions** – Users can react with emojis 👍️ 🎉 👏 🤘, adding
engagement and interactivity to the conversation.
@@ -66,7 +67,7 @@ requiring a separate Matrix client.
### 📲 In-App Calling (Widget Mode in Messenger Apps)
When used as a widget 🧩, Element Call is solely responsible on the core calling
When used as a widget 🧩, Element Call is solely responsible for the core calling
functionality (MatrixRTC). Authentication, event handling, and room state
updates (via the Client-Server API) are handled by the hosting client.
Communication between Element Call and the client is managed through the widget
@@ -90,7 +91,9 @@ and voice calls within Matrix rooms.
Element Call offers two packaging options: one for standalone or widget
deployment, and another for seamless widget-based integration into messenger
apps. Below is an overview of each option.
apps. A third, experimental option builds it as a React component library for
applications that want to render a call inside their own page rather than in an
iframe. Below is an overview of each option.
**Full Package** – Supports both **Standalone** and **Widget** mode. It is
hosted as a static web page and can be accessed via a URL when used as a widget.
@@ -107,6 +110,11 @@ recommended method for embedding Element Call.
<img src="./docs/embedded_package.drawio.png" alt="Element Call Embedded Package">
</p>
**Component Package (experimental)** – A library build of Element Call as a
React component, consumed as a dependency by a host application that already
has a Matrix client. See
[Element Call as a component](#element-call-as-a-component-experimental) below.
For more details on the packages, see the
[Embedded vs. Standalone Guide](./docs/embedded_standalone.md).
@@ -115,30 +123,33 @@ For more details on the packages, see the
For operating and deploying Element Call on your own server, refer to the
[**Self-Hosting Guide**](./docs/self_hosting.md).
## 🧭 MatrixRTC Backend Discovery and Selection
## MatrixRTC Transports
For proper Element Call operation each site deployment needs a MatrixRTC backend
setup as outlined in the [Self-Hosting](#self_hosting). A typical federated site
For proper operation of Element Call, each deployment needs to set up a
MatrixRTC transport in the form of a LiveKit server as outlined in the
[Self-Hosting Guide](./docs/self_hosting.md). A typical federated site
deployment for three different sites A, B and C is depicted below.
<p align="center">
<img src="./docs/Federated_Setup.drawio.png" alt="Element Call federated setup">
</p>
### Backend Discovery
### Transport Discovery
MatrixRTC backend (according to
[MSC4143](https://github.com/matrix-org/matrix-spec-proposals/pull/4143)) is
announced by the Matrix site's `.well-known/matrix/client` file and discovered
via the `org.matrix.msc4143.rtc_foci` key, e.g.:
Element Call discovers the available MatrixRTC transports (as defined by
[MSC4519](https://github.com/matrix-org/matrix-spec-proposals/pull/4519)) by
hitting the `GET /_matrix/client/unstable/org.matrix.msc4143/rtc/transports`
endpoint of the Client-Server API. An example response:
```json
"org.matrix.msc4143.rtc_foci": [
{
"rtc_transports": [
{
"type": "livekit",
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
},
]
"type": "livekit",
"livekit_service_url": "https://matrix-rtc.example.com/livekit/jwt"
}
]
}
```
where the format for MatrixRTC using LiveKit backend is defined in
@@ -149,13 +160,12 @@ via `livekit_service_url`.
### Backend Selection
- Each call participant proposes their discovered MatrixRTC backend from
- Each call participant proposes their discovered MatrixRTC transport from
`org.matrix.msc4143.rtc_foci` in their `org.matrix.msc3401.call.member` state event.
- For **LiveKit** MatrixRTC backend
- For the **LiveKit** MatrixRTC backend
([MSC4195](https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md)),
the **first participant who joined the call** defines via the `foci_preferred`
key in their `org.matrix.msc3401.call.member` which actual MatrixRTC backend
will be used for this call.
the **first participant who joined the call** defines which backend will be used for this call via
the `foci_preferred` key in their `org.matrix.msc3401.call.member` state event.
- During the actual call join flow, the **[MatrixRTC Authorization Service](https://github.com/element-hq/lk-jwt-service)**
provides the client with the **LiveKit SFU WebSocket URL** and an
**access JWT token** in order to exchange media via WebRTC.
@@ -178,6 +188,13 @@ discuss and coordinate translation efforts.
## 🛠️ Development
### Dependencies
- Node.js (e.g. via [nvm](https://github.com/nvm-sh/nvm))
- [Corepack](https://github.com/nodejs/corepack) (not bundled with Node.js anymore starting from 25.0.0)
- Docker client and runtime + Docker Compose (for the backend)
- On macOS you can install everything with `brew install colima docker docker-compose`
### Frontend
To get started clone and set up this project:
@@ -202,49 +219,165 @@ pnpm dev
See also:
- [Developing with linked packages](./linking.md)
- [Developing with linked packages](./docs/linking.md)
#### Element Call as a component (experimental)
Element Call can also be embedded directly into another React application
rather than being loaded in an iframe as a widget. `pnpm build:component`
builds it as a library into `component/dist` (the bundle, its stylesheet and
type declarations), and
```sh
pnpm dev:component
```
serves a harness on port 3001 that stands in for such an application: it signs
in twice against the development backend and shows two calls side by side, in
resizable boxes, with page furniture of its own around them. Use it to see how
Element Call behaves when it does not own the page — the size it is given,
whether it stays inside its container, and what it says to its host, which is
logged along the bottom. The harness is served with the same development
certificate as the app, so unless the development CA is trusted, the browser
needs a certificate exception for `https://localhost:3001` as well (see the
note under [Backend](#backend)). It reads the same `public/config.json` as
`pnpm dev` if one exists, and runs with Element Call's defaults otherwise.
The call lays itself out for the size of the element it is mounted in, not the
window: a host that shrinks the container to a corner of its page gets the
picture-in-picture layout, just as a host that shrank the whole iframe used to.
The breakpoints in the stylesheets the component uses are
`@container element-call` queries against its root element for the same reason;
for the standalone app the root is the page, so they mean what the media queries
they replaced did. (The standalone-only views, such as the home and login pages,
still use plain media queries, since the component never shows them.)
The component's stylesheet is confined to the element it is mounted in: the
build rewrites every selector so that it matches only Element Call's root or
what is inside it, with `html`, `body` and `:root` standing for that root (see
`component/build/scopeStylesToRoot.ts`). A host's own page keeps its styles,
and Element Call brings its own fonts and design tokens along.
The component speaks every language the app does. English is bundled in; the
other locales are split into chunks the host's bundler loads the first time
they are needed. It starts in the browser's language, and follows the host's
own language setting through the `language` prop (`supportedLanguages` lists
the tags it accepts, and anything else falls back to its base language or to
English). The `theme` prop works the same way and takes the same values as the
widget's `theme` URL parameter: `light`, `dark`, `light-high-contrast` or
`dark-high-contrast`. Both can change while a call is running without
disturbing it.
A host must call and await `initializeElementCall(config)` once before
rendering the component: it loads the `Intl` polyfills, applies the
deployment-wide `config.json`-style configuration and sets up translations.
The component itself takes the host's `client` and the `roomId` to call in, an
`intent` saying what the user asked for (which decides whether to show the
lobby, ring, and so on), an optional `config` overriding what the intent
implies, and an optional `hostBridge` through which Element Call tells the host
that the user has joined or hung up, that it wants to stay on screen, and so
on. The host makes its own requests (`join`, `hangUp`, `setDeviceMute`) through
the handle exposed on `ref`. The full API is documented in the type declarations
(`component/index.tsx` and `component/host.ts`).
A few things differ from the widget on purpose: the component draws a solid
background rather than a gradient unless told otherwise, never offers to edit
the user's profile (the account is the host's), scopes its keyboard shortcuts to
its own root element so that several instances can share a page, and only shows
its own post-call and error screens when the host has not supplied a `close()`
callback; with one, it asks the host to unmount it instead. The
[global JS controls](./docs/controls.md) on `window` are unchanged and remain
page-wide, so with several instances on one page they apply to all of them.
The package is not published yet. A host installs it as a git dependency on the
`component` directory of this repository,
```json
"@element-hq/element-call-component": "github:element-hq/element-call#main&path:/component"
```
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,
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
directory target it rather than the repository; run them from the repository
root. The host imports the component from
`@element-hq/element-call-component` and the stylesheet from
`@element-hq/element-call-component/style.css`, and has to provide `react`,
`react-dom`, `matrix-js-sdk`, `@vector-im/compound-web` and
`@vector-im/compound-design-tokens` itself, since the bundle leaves them
external.
Compound's stylesheet are the host's to load too: the component shares
the host's copy of Compound, (required for example for tooltips)
The component is large, and a host will usually load it lazily, only once a
call is shown. Everything a host needs in order to talk about a call before
then is also exported
from `@element-hq/element-call-component/api` (a small entry point that does not
load the component).
- props and the types they are made of
- the handle
- the host bridge
- `UserIntent`
- `HeaderStyle`
- `BackgroundStyle` enums
- `configurationForIntent`, the defaults each intent implies
Import from it wherever a value such as
`BackgroundStyle.Solid` is needed on a path that must stay light; the main
entry point re-exports all of it too.
### Backend
A docker compose file `dev-backend-docker-compose.yml` is provided to start the
A docker compose file `docker-compose-dev.yml` is provided to start the
whole stack of components which is required for a local development environment
including federation:
- Minimum Synapse Setup (servernameis: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note requires Federation API and hence a TLS reverse proxy)
- Minimum Synapse Setup (servernames: `synapse.m.localhost`, `synapse.othersite.m.localhost`)
- MatrixRTC Authorization Service (Note: requires Federation API and hence a TLS reverse proxy)
- Minimum LiveKit SFU setup using dev defaults for config
- Minimum `localhost` Certificate Authority (CA) for Transport Layer Security (TLS)
- Hostnames: `m.localhost`, `*.m.localhost`, `*.othersite.m.localhost`
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browsers trusted
- Add [./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt) to your web browser's trusted
certificates
- Minimum TLS reverse proxy for
- Synapse homeserver: `synapse.m.localhost` and `synapse.othersite.m.localhost`
- MatrixRTC backend: `matrix-rtc.m.localhost` and `matrix-rtc.othersite.m.localhost`
- Local Element Call development `call.m.localhost` via `yarn dev --host `
- Local Element Call development `call.m.localhost` via `pnpm dev --host `
- Element Web `app.m.localhost` and `app.othersite.m.localhost`
- Note certificates will expire on Thr, 20 September 2035 14:27:35 CEST
These use a test 'secret' published in this repository, so this must be used
only for local development and **_never be exposed to the public Internet._**
Run backend components:
Make sure your Docker runtime is running (e.g. via `colima start`) and then start
the backend components:
```sh
pnpm backend
# or for podman-compose
# podman-compose -f dev-backend-docker-compose.yml up
# or for podman-compose:
# podman-compose -f docker-compose-dev.yml up
```
> [!NOTE]
> To ensure your local development frontend functions properly, you’ll need to
> add certificate exceptions in your browser for `https://localhost:3000`,
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz` and
> `https://synapse.m.localhost/.well-known/matrix/client`. This can be either
> done by adding the minimum localhost CA
> add certificate exceptions in your browser for `https://localhost:3000` and
> `https://matrix-rtc.m.localhost/livekit/jwt/healthz`. This can be done either
> by adding the minimum localhost CA
> ([./backend/dev_tls_local-ca.crt](./backend/dev_tls_local-ca.crt)) to your web
> browsers trusted certificates or by simply copying and pasting each URL into
> browser's trusted certificates or by simply copying and pasting each URL into
> your browser’s address bar and follow the prompts to add the exception.
### Updating snapshots
To update snapshots used in tests, use Vitest's `-u` flag, e.g.:
```sh
pnpm test DeveloperSettingsTab -u
```
### Playwright tests
Our Playwright tests run automatically as part of our CI along with our other
@@ -257,7 +390,10 @@ running Playwright by following
However the Playwright tests are run, an element-call instance must be running
on https://localhost:3000 (this is configured in `playwright.config.ts`) - this
is what will be tested.
is what will be tested. The tests under `playwright/component` instead drive
the component harness (`pnpm dev:component`) on https://localhost:3001, which
Playwright starts as a second web server; it is always a Vite dev server, even
when the app itself is served from Docker with `USE_DOCKER`.
The local backend environment should be running for the test to work:
`pnpm backend`
@@ -344,9 +480,19 @@ Usage and other technical details about the project can be found here:
[**Docs**](./docs/README.md)
## GitHub Labels
GitHub labels in this repository are maintained in the [`labels.yml`](.github/labels.yml) file and
automatically synced to GitHub using the [`sync-labels` workflow](.github/workflows/sync-labels.yml).
We do this so that we can reuse the labels between repositories.
> [!WARNING]
> Do not manually edit labels in the GitHub UI. Any manual changes will be overridden by the
> workflow on its next invocation.
## 📝 Copyright & License
Copyright 2021-2025 New Vector Ltd
Copyright 2021-2026 New Vector Ltd
This software is dual-licensed by New Vector Ltd (Element). It can be used
either:
-20
View File
@@ -1,20 +0,0 @@
module.exports = {
presets: [
[
"@babel/preset-env",
{
targets: {
node: "current",
},
},
],
[
"@babel/preset-react",
{
runtime: "automatic",
},
],
"@babel/preset-typescript",
],
plugins: ["babel-plugin-transform-vite-meta-env"],
};
+17
View File
@@ -0,0 +1,17 @@
id: "lk-jwt-service-othersite"
as_token: "ec_dev_as_token_othersite_0123456789ab"
hs_token: "ec_dev_hs_token_othersite_0123456789ab"
sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:synapse\\.othersite\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server-1:16080"
# Enable membership look-ups via /is_joined.
io.element.msc4502.scopes:
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
# service.
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://auth-server-1:16080"
+17
View File
@@ -0,0 +1,17 @@
id: "lk-jwt-service"
as_token: "ec_dev_as_token_main_0123456789ab"
hs_token: "ec_dev_hs_token_main_0123456789ab"
sender_localpart: "_lk_jwt_service"
namespaces:
users:
- exclusive: false
regex: "@.*:synapse\\.m\\.localhost" # Cover all local users
# Required for Synapse to call POST /_matrix/app/v1/ping on this service.
url: "http://auth-server:6080"
# Enable membership look-ups via /is_joined.
io.element.msc4502.scopes:
["urn:matrix:client:io.element.msc4502:rooms:is_joined"]
# Route /_matrix/client/(v*|unstable/*)/rtc/livekit/* C-S and S-S requests to this
# service.
io.element.msc4512.proxy_prefix: "rtc/livekit"
io.element.msc4512.proxy_url: "http://auth-server:6080"
+14 -1
View File
@@ -40,6 +40,15 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -67,3 +75,8 @@ rc_message:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt
+14 -1
View File
@@ -40,6 +40,15 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -67,3 +75,8 @@ rc_message:
# Currently the heart-beat is every 5 seconds which translates into a rate of 0.2s
per_second: 0.5
burst_count: 30
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt
+5
View File
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50300
port_range_end: 50400
use_external_ip: false
# Advertise the loopback address instead of the container IP, which is not
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
# The browser then reaches the SFU through the published ports. Firefox
# ignores loopback candidates, so this only works in Chromium-based browsers.
node_ip: 127.0.0.1
turn:
enabled: false
domain: localhost
+5
View File
@@ -6,6 +6,11 @@ rtc:
port_range_start: 50100
port_range_end: 50200
use_external_ip: false
# Advertise the loopback address instead of the container IP, which is not
# routable from the host when Docker runs in a VM (colima, Docker Desktop).
# The browser then reaches the SFU through the published ports. Firefox
# ignores loopback candidates, so this only works in Chromium-based browsers.
node_ip: 127.0.0.1
turn:
enabled: false
domain: localhost
+2 -26
View File
@@ -1,4 +1,4 @@
# Synapse reverse proxy including .well-known/matrix/client
# Synapse reverse proxy
# domain synapse.m.localhost
server {
listen 80;
@@ -11,18 +11,6 @@ server {
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# well-known config adding rtc_foci backend
# Note well-known is currently not effective due to:
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
# says it must be at https://$server_name/... (implied port 443) Hence, we
# currently rely for local development environment on deprecated config.json
# setting for livekit_service_url
location /.well-known/matrix/client {
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://synapse.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.m.localhost/livekit/jwt"}]}';
default_type application/json;
}
# Reverse proxy for Matrix Synapse Homeserver
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
@@ -44,7 +32,7 @@ server {
}
# Synapse reverse proxy including .well-known/matrix/client
# Synapse reverse proxy
# domain synapse.othersite.m.localhost
server {
listen 80;
@@ -57,18 +45,6 @@ server {
ssl_certificate /root/ssl/cert.pem;
ssl_certificate_key /root/ssl/key.pem;
# well-known config adding rtc_foci backend
# Note well-known is currently not effective due to:
# https://spec.matrix.org/v1.12/client-server-api/#well-known-uri the spec
# says it must be at https://$server_name/... (implied port 443) Hence, we
# currently rely for local development environment on deprecated config.json
# setting for livekit_service_url
location /.well-known/matrix/client {
add_header Access-Control-Allow-Origin *;
return 200 '{"m.homeserver": {"base_url": "https://synapse.othersite.m.localhost"}, "org.matrix.msc4143.rtc_foci": [{"type": "livekit", "livekit_service_url": "https://matrix-rtc.othersite.m.localhost/livekit/jwt"}]}';
default_type application/json;
}
# Reverse proxy for Matrix Synapse Homeserver
# This is also required for development environment.
# Reason: the lk-jwt-service uses the federation API for the openid token
+17 -17
View File
@@ -1,19 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDGjCCAgKgAwIBAgIUbSbx+1UGptOTGefqEn7Zh3yoChIwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDHODfkrFsOkqCnXnTb
QWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+1azC
SlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+I9O6
NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBhwLyC
DW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+Liyt
X/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPERYS2H
Ru/RAgMBAAGjUDBOMB0GA1UdDgQWBBTXNfLAKVayGQda/JZLPszrpz6LVzAfBgNV
HSMEGDAWgBTXNfLAKVayGQda/JZLPszrpz6LVzAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQCvGfyopHHgZB+horGH6i/Xg41V+r4d0o092F1Lfr4vh86e
XMakRw92vsyk/iWOnLPNPcpVWzPcvINaCs/bahgnGSOAnrA4jjcXqymyGIy/6xc5
1EeZAxehiL9E5q4LQ841HDX0gps4ZzUO1BRYQcjG9Rdts83JO2ekkfHkZdNj2eQr
KOrr92Na1/w+EQdo/T9Rs2ES623xKEOxPqb8d/rx5Z4DdeuGx1u+3AfS76Lpo4ni
EJ0g1ImqdSUtiOLzeCQh6pqqb+vuFbxAyeyYSAJ49847EtFBvZCmWmPL2JICg9uq
7rKW/qDfEK9GUs0GWCs3+mJkNvOOxBwtMuQrL7ZF
MIIDGjCCAgKgAwIBAgIUOlA2wgQUGZkKqNDvvifFWEsJfvYwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
MzFaFw0yODA3MjYwOTM0MzFaMB4xHDAaBgNVBAMME0VsZW1lbnQgQ2FsbCBEZXYg
Q0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcImv3pStfIUo7PbOO
XRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1cM74
RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh74yqN
B5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8+sGW
WGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2oQ/s5
mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBlLa+3
7dhLAgMBAAGjUDBOMB0GA1UdDgQWBBRDfyRM4yKUqW6vu/2KUSXGb8vswDAfBgNV
HSMEGDAWgBRDfyRM4yKUqW6vu/2KUSXGb8vswDAMBgNVHRMEBTADAQH/MA0GCSqG
SIb3DQEBCwUAA4IBAQBoAhD4W4Yi/VJ2pTKrzhstn1UF1rgQnRddnn97v5BaEV/X
uuBXbSO+/ewjQUupRjePZFp9FFe9co1OiduKcDExlvPU1eIqkWAwDWjMDpI+Lw5q
KI5yHzplmMrT/7jn9Tepl9atrIcfDeFkP1dGtdRPyU6ARJEEWJSKeH9ftmImAsbM
ykXAqSyRl8+bPx1ISG4cNihOxFd38VPDHIW53umaRBgRcN4GcvloKBGrVtRFNM//
H+md8HmNQMP+e7FamETxs28DxjsdpygxjiFNY/T2eD67dH50ZxC3qCxEG6TJsoAg
TYJafnqEcGDfiWQyNZRBypuaRsRmmTR27hCPVgi8
-----END CERTIFICATE-----
+26 -26
View File
@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDHODfkrFsOkqCn
XnTbQWz3LkUtNCoVvM7wFouirRnITJYG+lFwF+zNl89Eaq+uUN4bwd8ml1ZuR9p+
1azCSlklD5adhCR/ErknfUWamQEf6amSs3p0NnqnhXbnDEEbQOwNaPU/aGc6aw0+
I9O6NQ/H830GlVuKd24Bfv0mx6Imo0Hi9jxKYhqFh80nmltk2uyXefaJxuo1jXBh
wLyCDW8RVj55QvkZyBUzid8yslxrlo0LHKCCjZflwJJv5f+jaubkH5c0qxVaoR4+
LiytX/4viIwt3Mhj04ppudTvt973mTbjRG5haCz9y7OkT1mMWhc0xrdMFX+gjPER
YS2HRu/RAgMBAAECggEABhB9CxYAE5p9D3s9nWsJcSDUdELRQSYlOoPFLmeMkF9c
dcvq7LmduMh1Q8TnoivOBxRIwbj7pZHEYfYJM0TmH82wrQzXu5KLVltm4gTkVt9b
DR8vjBgYdb8HVpM17Cl2xhW62XpJIiseFRUsHc/9sf2Egc3MIpPuIleGR0budbSW
ybBkqEokTYTSiAztcu3G+VN0U9MsJgLMa8HApya7M48ojdrhzngVHZRUOXul9o7u
zYJWSxPHIIYp5C4pYQBAx8OttThwKK1A9lwbQ2EJx0KnTbBC6O5Gna/jENpGd1h2
rzK/9MONtsjln7IejP+4mDlNupS6SF3zzHPBHjqKAQKBgQDtXUIKPiVTFS45yWtK
XD62s3j8jfIi+22b/C30fCPtppn0cm/0zY+vovgWVUBnQXkExafRthZCuxnE8ry7
E29S40+4z9yivAC9dz7vHZUbyIFP6VG9WyhUYo+/WqOIePyh+iBISQ9TA1DneIYz
+VZ8iU5GvdybUPl2C5WN8seaoQKBgQDW3EwVN2EEkChLRJbQYN2qpjn+0vYESMJ8
K0sgMRtgh4+/T2Xb9b8O/dd87Fi/4oaUqWZ2E2sdsXq8P/IEo0cv6SRfHMy7GyxL
RM7ztwUfMC4LVWi0ZIXMrm4gRDGN2XjGvhkX6fU2lSf6azWL1K3wI3amNV2b7P7d
ItpvdkH3MQKBgQCXf29YJEQkXB9t6J3fDzND3xb4cwy5wSo7ZeBa7CTuWOhoeeX1
JIJyAp0/e9goT0SThChRlFtu6gZPivJkoMnr6IOInLrg7we15fc4HPR/kCDgxTVT
m2wJOAMxigNYZogwRfn2yRLL1BD+PBHD+H936xcX1bSJOUyPSGOC/xLhIQKBgQCb
kCDd85ygyycBaAWxlZCor3WqFF/fNjbp5Aaepi9mMoBXSUs8eK7+UbelURHozEAY
fpYaw3B4rTlp9vppdTZjb+/PlXB9v+zQCl+0gTyKGj4cIpiOk4F0co51eipOw7f4
XUaZ0+CgxlmNq/W26iONjH+pU1YVQQA+Z6+zp/GW4QKBgQCrzYgeugxxqgJzyIRu
0njJkIg+T5gHvsQrtpzq7LVob+HBiBiT7eDOeGDXTK8F//sk969QVrDMQsTMvGW9
sG1oTqxciALTMqkJTf8+hT9Uogir0/iTbJUzTt5vPYpQOEQwQHIXMUTjZ9C6NDKT
QlmeMCxeWyPYqoMfwKmdtDP/Iw==
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCcImv3pStfIUo7
PbOOXRVUXuDlApBOrg2dCnvZQ1Jfaf4MftGHj/pURkF4eoBuyH4k4+NLzWD0VcU1
cM74RnxowJt4AceCGe5RK/rqal5fapXc2vYMM8P6xaQR86gkxohpufsLgTnSweh7
4yqNB5WHUnCX00/X0bh1ho2BMUvGM9+dI4MdgKdaQDgWK4zg9hwp2Z6Yq7SkJ/D8
+sGWWGpn3osDakL8HTBqop+YVJgF40db50yFurfcfQ0gjVtT4JW8ejO9j8PS/S2o
Q/s5mA1B470XhLtT5qTjGm2bp3WpYkTi5widps8PDzBp5eNr0HrvJqcw7BGpbvBl
La+37dhLAgMBAAECggEAEsS4gc5jBk50I+bo3KYn2DqHgj/qpOqbTFNkS9uh3UJa
fZoJCeiuyM6hNCBVq/uB3mFeg1Au5XAiAqiK2KFwdw8gIS7lkqgXU76brO4YZhPj
6+aOSS03079KV7YYckNDRqJKoTlpgAI7Nhk6ljVhLiEk07tdD65wJACGpg8M8sg9
dyAz+ANs9gs65iF5LYjH61O/AFlLqCRQh5/z0mjGX6G9uN27nxeUY4+n4QMAcd9D
Gcygxjt+4nlQayNAlKMwVfps9bWNtI3Ye9knY4WGkrv5cJbW3bgjV6qrvQsbukbq
xEYzcIlUiWGO9Tv7MN6rk5uQOyoKT/KUnfRmdVd3YQKBgQDMhWm6Q+WuI7Pyn57R
tmY4rs+fSqTmv6xAOcozKJxffGaEwSUuNA15NvR/7iedcNqmH3XT9j90ZNVHe090
ocm1HDUvzC9G5GRrdO6JTTksRaIMZEhosWxqH3DIuBJPLGbF/4obGE7//PJtmDEp
QVL9Aa0WrcwAWhRzUdvCE+taMwKBgQDDbyZIvtlEr1w2V0bjXO536rRksBapc2ZJ
XRKtrXivuVtiZYNDB0I7CCJ52cka61n3kyZz2mhQmLq4cAZXyKYWE2i643O+kc3S
lpZEFSfDZ+3YlhxMxG9oEcgUSwVdbPlAhd/UR8V6n8o2Fm+gug9h6E2zY4fgHLJF
8hOWoD4hiQKBgA7YXD1F8mT6eHRS+78zIyZYIf/o9iE9pm4fA7tE5lzT9ckLD/zT
kGrM/2BN1BhMecJ3JCFXjXGQZB7FJ5ZKrA52VrH6ezAFIfjeyvWyYkUBZOrLWKoo
vrrRP2mCWuneSjNzAf5HfGx+WsZztpXNBQ4SUhMEWHtqDnP0bCQhOAMbAoGAPfLv
qcOFT3ZevoLv34ZHuQ9W20vOAyynUb4E+7SvOtSAmTIgZ5DXd6recs2MJ9JOlGG6
oKKsyk9/cJNiD1V1AC5q1kLfH5tMKOK/AxnJnvFEvZDnq5Xg0pZAW95j9vdiEwfc
qYeOm44nJPn7rHEOCzT93E1CdtHh2LYha2+kAjECgYEAh4qODleBi+2fnf2eq494
/tAot3szu2+gjyCN00vGjtzoAuDKTYgo0cbU1ILk0Pgpp1NcIvdHz/wQnG9RLX7e
Dfy1Q+UkyBK67SJUPvcYqBEaZ6ddyijJDunqh+U3nIBGP+IntKIKMIKiLF6wzTKz
NRpK1HNmllp+O692ZtxoNDU=
-----END PRIVATE KEY-----
+19 -19
View File
@@ -1,21 +1,21 @@
-----BEGIN CERTIFICATE-----
MIIDgDCCAmigAwIBAgIUT9NYpZbrAKokSPSTE3zzsAMowvEwDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNTA5MjIxMjI3
MzVaFw0zNTA5MjAxMjI3MzVaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDK1DNwTQWmyK71Ar56NvmSMQ8s
qUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgfJlZMd4lEvt6V
EEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+7Z22i6cGMOnQ
0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6WxJmFF+eV9P/ax
fxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiVG1jCrVcaKNGT
PYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrncneIJR24/AgMB
AAGjgbswgbgwHwYDVR0jBBgwFoAU1zXywClWshkHWvyWSz7M66c+i1cwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwSQYDVR0RBEIw
QIIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3QwHQYDVR0OBBYEFIkGX+cEJ1ISKIwuT1zzp7uHJ90e
MA0GCSqGSIb3DQEBCwUAA4IBAQBnnnfB7KmyYo16ZYUCmoqGhbM4p8npeYTh5ySb
K01YwGCnMU1qGfJnKHaRwQ2+KtVGZnpBdjmsHcOUetA3V2BirPaYowMCMtaI36LD
LnxvboSZLX0mgEYuN7HmxW4a7fSelDecTYa7xti1sNhE/w8xW7Lky046/DousyUy
d9x3wJ183GGj1W2p6bR1E4sqTr/VbmoULQxnqA3GUNOxW3lRL5e8lQ6jJVRmMF4k
92BtMPrI/m7jwHj0f/WBLI8mdJ/O/W/NxQOG475FZePDfrg+MkeXPChPggf42/ou
AMm56FNB7e1l0b1Fots730RfpCPuXpiAxL4pisS0X1dMVeeM
MIIDijCCAnKgAwIBAgIUWkx2ad/F7QIj1JDaYfbLhiRV+EswDQYJKoZIhvcNAQEL
BQAwHjEcMBoGA1UEAwwTRWxlbWVudCBDYWxsIERldiBDQTAeFw0yNjA1MTgwOTM0
MzFaFw0yODA3MjYwOTM0MzFaMBgxFjAUBgNVBAMMDSoubS5sb2NhbGhvc3QwggEi
MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC0hora/UCYMtrLJc6BOjonPUPi
bYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhlXpkRqIiWPml8
sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3oIkUihveXAl6H
C0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6JvwS+8Kv5/QV
5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32xO/iqtDuDQzw
Q09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE64W4PRJp3AgMB
AAGjgcUwgcIwHwYDVR0jBBgwFoAUQ38kTOMilKlur7v9ilElxm/L7MAwCQYDVR0T
BAIwADALBgNVHQ8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwUwYDVR0RBEww
SoIJbG9jYWxob3N0ggttLmxvY2FsaG9zdIINKi5tLmxvY2FsaG9zdIIXKi5vdGhl
cnNpdGUubS5sb2NhbGhvc3SCCCoubmlwLmlvMB0GA1UdDgQWBBSd0sKIKmZzTnxT
gNHHjsJNnFcYaTANBgkqhkiG9w0BAQsFAAOCAQEAeffRTrD9o9PVRIoul5r2chwP
WF7JtvPdC5xWy9rlCfmIKRNzNRnpVw/mDF/jdhlWcENt3psN8Vb1NM3SECKve9KL
8bDD2rJEoLBHIFQPS+XpEPqVGLHQcfBtGgs2XdILKvgXJyBHY/pgNZkQmXxYDVoc
bH9PjJJ4V3t6+tiVWZ792739EU/pHaSz7tab+ycTiggs7mo18E5jpYILhWsDqIVs
Kz3uczK2OR8537Ix64Z9kmKiklVAqE53odV7Qx2B+7DoOD/7KBN7SMy1KvR1ae6I
p1ivtDKpBZWbb1ccFxp2cQ30qRHLJrt2YRwz268gx/A6rGXuW6UQPYf4ISNR4Q==
-----END CERTIFICATE-----
+26 -26
View File
@@ -1,28 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDK1DNwTQWmyK71
Ar56NvmSMQ8sqUY3jGqqPVORjfDUtDCrPPdCxT+ZlnsAgdonElWoWqczMrSyBRgf
JlZMd4lEvt6VEEiZGUvA/lG1XIVgrx1kMSHKBoJj7lCBN6r3IWmYe6CxgfZurgp+
7Z22i6cGMOnQ0XduX5Asup6zk5V7AE6i9eKrJsUjYmRBXtk099IitkER4TMqh6Wx
JmFF+eV9P/axfxkon+bQWITwP1PLC1UOTK7lR0EcVan5aY6WMs/6RfO4Gw/dvuiV
G1jCrVcaKNGTPYqmQqs+MOvyIqJ9kYELRZu+6bhPWSXk2ESpSIUIPH9twfnmWrnc
neIJR24/AgMBAAECggEANRp6vzfDN4gKWoaV5TyYegCTNv+4rRl74cd9pjmx6Jam
uWaUXCx1etpNqPPWcG1Z9OKLLRnk+kjgKGOqq4mObGvGreNeBot7bHOJZADtwMMI
YG+Gp7StlclS1YoEHoDmezA/AcqDgTXa9KF0rdMBb1sGFJCLAuBNSJCxtVV6CQIz
X26uT0m+Wx8MQyQWA7Sqy6DQNJo++IZkvr7a3cidqBOUPs+QvnIV5JsUb2gp5tGn
zk+ObeRjoFFWYAN/NK7bneRenkP40m3MSL8ZfaEuuonui7CrxM1SiQyq2N1u/Aoy
OE1JtNaVPbLBo6kG5al7Sj4Z0zhRt+iv93S2lZMkBQKBgQD2+FpLTqyLO1NDOFkE
kxU+LdLOx0OV9wASC0ApPOu1dHMG6ksByr7TWeiu6GJDgajusPB7NVPOt2cm4iWU
xPxXPO5l87uiSvu80h5uG4Qdj8KEijHkdap2wbVkU/mm8lBKC36jyBQIlJKySyXY
zSEMfLK9jQPKz5cKKT3dVj/fAwKBgQDSPq9oks6K96MAB66o6cm214otQlnTQkPM
xgjtjddX+Lp9tgihGvtSfPbyy89oUDHCfKvW/AHG52e5dec5YUi6mVdHEWbk33Kt
BoQuxeK3XseIDlD/JD9Dd7KfUyO5w2jtYLfNdqez41O4qj2N52m1KwJYTwMsc8Kq
izVgkC5hFQKBgBFAc/5CtqbbNAvECePZ6mf3h3xOSxhUsrqP8qFu0gBQ7CAVibvM
T9wvsaNWNFcG3age0A2rQfl0sk3zCjEEOaRWa0jP59GEb2VXQCzs2yO9gRcFGEsf
NRMqoOMrQos47gbeGrCSL2QSDNVLjo9AdQiMRWgcS6GFMsXQ77NgbQHFAoGBAI4a
YGTGFWRITJvQlXUFz5kNxg8hMaVgvILDt3UY0dxb+XDOgLajjgsK+77Pkrhmu7tA
mMUOQAU4kxr/XfGil43H5v3Z/Tnk7ZWVOfKDPeHC5gpH4ucQkNIBLXISt6rvMRSA
srrk4CTuGcBPEJvBNemF0Gfvv61j8MdkoAdMbIyhAoGAfGR6yZLBmRMsW5PKmcpT
nq2oSeUpmtGZra6pWz/3XU7AgrCLcx1DmqEjm4w7y5NQJmxyMZqqdTJILCjr3Srt
+2F0NqQL6Li+xQGibAvDj0Jxyol38RvFC0J/w2vQmuF0hTuH95yknSd7FPXK+DPG
qYgXLjun9dht6kx9vGJ69wI=
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0hora/UCYMtrL
Jc6BOjonPUPibYpbNiZYnvnqI4doKbV0LBT2TfokT7tpgdPCtHKV0RknsVSL8vhl
XpkRqIiWPml8sZaa0+5NDGCQxexS2WVBlsoNCmAaqi/HNSFop6xaxGpQ3bu0iV3o
IkUihveXAl6HC0VYyGifQ8D5onzepW2ayhemu47YRNSo8wETY5vIi0i/iajTTaw6
JvwS+8Kv5/QV5prdvcFlG/oBs12p0+KoRyxskyzcdBdyIarvfY+9nDZwym5GfN32
xO/iqtDuDQzwQ09h2OsfHJCw70IpHcgXLlEQF2DsFbmbVpWSU6HcMm6B7Yw1YeE6
4W4PRJp3AgMBAAECggEAIgdIbk4VmnrfjjKCsg5JPvNH9AsE7PuQj9zrq+xljkdq
aksS6ni5YZXb9F/iDE4aWU4waTB+iODUXLtPrCnyESwTk0sgYe/39/MQ0slUKivL
b+keDgY6JlyVI/5KXWFZ1kQ27CZXxwiruGGZWZBKZF8wdVE1Ea65Neg+HHA6DHee
Jck002gtgO/J1MMbB1MzdtGcsejYLrA+mO6YddQhA65xdQMljTEfyUwgTVv0pWde
biyKegGK77vlsOyoCkMpVYORG5NMV1Kxs+htA79yuIW71tWHqVbcRMyoM+BaHzPh
7uprs+8vYDFrO39LseczA8gURWwUsCgQ0yQ6Ix5W7QKBgQDnSK4AzjPpDEArdHuV
VGKyzrfPtzH0VV/yTH9hvByNG6i/x8sE/r2KPi5nRMi4PAjjqmxyO1G5qwDOfzvK
sBvwFrTRpmbnqGITVKPPivdoI9+RveN+FxhOXVA8NylAOv/dtSoakYwg3e507UsC
RuFW3Re0Oc+0XFq4C8rQyLkIOwKBgQDH0T9gww+XbwIGCtiNpnEziU9FXBKSVwXf
dCxYcTLPATq3BqHmP4OUA0v+sa3wPcnBkXF7q6eoB9+S6ZYQA/b2BXGU5/j9xYd4
29cF4DlPkhTwF9S8b+h1zhlGIn96Lw/vZuj7Bc3wuwxvB17d8dpyo8bZynIe7BvF
KSPJz+2O9QKBgFFyd8xS0VcFeGeVKpwozmUXhQWCBvZ7RkGGjOk3HHrYvbFjw2vr
5YWUZjT5tRGkGqFJ98y2dQ5EWRFfHwg+wmfnJyAZUG3OD1OtX86Lqpqi321siHtz
2JxoIgRCjKVQ4aAK11vp24YLgZjto5eWrG4xh9Jw9WMXjt73UCH8PaTXAoGBAIff
TY1qlmuO3H1nWqHXkBpPQEwVs7s22ZN817q8HqSMXXSfWe/LOJmpND/YakJ2gX7S
e6xwqOylje3EUHpLd98LDJUIuFM3wkr4klo4gkANQZeRXONV5WhV4PHD+5MF9XwB
KmOnKsaLKoVFKckZ8EUMAOePtdI5ExkaRG+yqAMRAoGAJyUFK+V9ST1N/6wYgqor
vywMSRE2cF2WvVIxdMvWffmpoj40bG6lAlaSWm29E2T8SVvAKsRid0wDgCQ4QTEn
ft7yUDjqVALCJVCrOFHDY0BPStkm6njMWagr/0lGr9zUWqbBOKJhNfDJlykv8gaF
8kWTgabrMCKmpTi7fBWbzZA=
-----END PRIVATE KEY-----
+3 -2
View File
@@ -3,7 +3,7 @@
# Step 1: Create a Root CA key and cert
openssl genrsa -out dev_tls_local-ca.key 2048
openssl req -x509 -new -nodes \
-days 3650 \
-days 800 \
-subj "/CN=Element Call Dev CA" \
-key dev_tls_local-ca.key \
-out dev_tls_local-ca.crt \
@@ -21,7 +21,7 @@ openssl x509 \
-CA dev_tls_local-ca.crt -CAkey dev_tls_local-ca.key \
-CAcreateserial \
-out dev_tls_m.localhost.crt \
-days 3650 \
-days 800 \
-sha256 \
-extfile <( cat <<EOF
authorityKeyIdentifier=keyid,issuer
@@ -35,5 +35,6 @@ DNS.1 = localhost
DNS.2 = m.localhost
DNS.3 = *.m.localhost
DNS.4 = *.othersite.m.localhost
DNS.5 = *.nip.io
EOF
)
+14 -1
View File
@@ -40,6 +40,15 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -84,3 +92,8 @@ rc_login:
rc_registration:
per_second: 10000
burst_count: 10000
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.othersite.m.localhost/livekit/jwt
+14 -1
View File
@@ -40,6 +40,15 @@ experimental_features:
msc4222_enabled: true
# sticky events for MatrixRTC user state
msc4354_enabled: true
# MatrixRTC
msc4143_enabled: true
# Enable membership look-up via /is_joined.
msc4502_enabled: true
# Enable C-S & S-S request proxying for application services.
msc4512_enabled: true
app_service_config_files:
- /data/cfg/app-service.yaml
# The maximum allowed duration by which sent events can be delayed, as
# per MSC4140. Must be a positive value if set. Defaults to no
@@ -54,7 +63,6 @@ enable_registration_without_verification: true
registration_shared_secret: "test_shared_secret_for_local_dev_only"
report_stats: false
serve_server_wellknown: true
# Ratelimiting settings for client actions (registration, login, messaging).
#
@@ -84,3 +92,8 @@ rc_login:
rc_registration:
per_second: 10000
burst_count: 10000
matrix_rtc:
transports:
- type: livekit
livekit_service_url: https://matrix-rtc.m.localhost/livekit/jwt
+20
View File
@@ -0,0 +1,20 @@
# Coverage comes from a single vitest run (see .github/workflows/test.yaml), so
# the app and the SDK are split by path into codecov components rather than
# uploaded under separate flags. Each component gets its own coverage figure in
# the PR comment and on codecov.io.
#
# Validate with: curl --data-binary @codecov.yml https://codecov.io/validate
comment:
layout: "reach, diff, flags, components, files"
component_management:
individual_components:
- component_id: app
name: app
paths:
- src/**
- component_id: sdk
name: sdk
paths:
- sdk/**
+57
View File
@@ -0,0 +1,57 @@
/*
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 } from "vitest";
import { render, screen } from "@testing-library/react";
import { type ReactNode } from "react";
import { Tooltip, TooltipProvider } from "@vector-im/compound-web";
import { CallPortalRoot } from "./CallPortalRoot";
// A button whose accessible name comes from its tooltip, as every icon button
// in the call does: the name is lost whenever the tooltip has nowhere to go.
const LabelledButton = (): ReactNode => (
<TooltipProvider>
<Tooltip label="Microphone">
<button>🎤</button>
</Tooltip>
</TooltipProvider>
);
test("floats into the host document with no root", () => {
render(
<CallPortalRoot root={undefined}>
<LabelledButton />
</CallPortalRoot>,
);
expect(screen.getByRole("button", { name: "Microphone" })).toBeTruthy();
});
test("floats into the root it is given", () => {
const root = document.createElement("div");
document.body.appendChild(root);
render(
<CallPortalRoot root={root}>
<LabelledButton />
</CallPortalRoot>,
);
const button = screen.getByRole("button", { name: "Microphone" });
const label = document.getElementById(
button.getAttribute("aria-labelledby")!,
);
expect(root.contains(label)).toBe(true);
root.remove();
});
test("waits for a root that is not available yet", () => {
render(
<CallPortalRoot root={null}>
<LabelledButton />
</CallPortalRoot>,
);
expect(screen.queryByRole("button", { name: "Microphone" })).toBeNull();
});
+26
View File
@@ -0,0 +1,26 @@
/*
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, type ReactNode } from "react";
import { PortalRoot } from "@vector-im/compound-web";
interface Props {
root: HTMLElement | null | undefined;
children: ReactNode;
}
/**
* Where Compound floats the call's menus and tooltips. With no `root` the call
* is not wrapped at all and they go into the host document's body; `null` is
* Compound's "not available yet", and the floating parts wait for it.
*/
export const CallPortalRoot: FC<Props> = ({ root, children }): ReactNode =>
root === undefined ? (
children
) : (
<PortalRoot root={root}>{children}</PortalRoot>
);
+51
View File
@@ -0,0 +1,51 @@
/*
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 container a host mounts us into. It fills whatever space the host gives
it, and nothing we draw may leave it.
That takes two separate things, which are easy to mistake for one. `isolation`
gives us a stacking context, so nothing inside can be layered above the host's
own interface. Containment makes us the containing block for `position: fixed`
descendants, and clips what we paint to our own box: without it, the modal
scrim and dialog — which are positioned `fixed` and centred, since in the app
they are meant to cover the page — resolve against the viewport and appear in
the middle of the host's window rather than in the middle of the call.
The clipping cuts both ways: a menu near the edge of a small container is
trimmed rather than overflowing into the host. That is the trade being a
component rather than a page makes. */
.root {
display: flex;
flex-direction: column;
inline-size: 100%;
block-size: 100%;
isolation: isolate;
contain: layout paint;
position: relative;
background-color: var(--cpd-color-bg-canvas-default);
color: var(--cpd-color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
-webkit-tap-highlight-color: transparent;
}
/* The call itself. It is a stacking context of its own, as the standalone
page's `#root` is, so that the layers the call view uses for its header, footer
and overlays stay below the modals and toasts, which are portalled into the
root and carry no layer of their own.
What Compound floats — tooltips, menus — is not in here at all: it goes into
the host's body, as it would for any other component on the host's page, and is
styled and layered by the host's copy of Compound. */
.content {
display: flex;
flex-direction: column;
block-size: 100%;
position: relative;
isolation: isolate;
}
+138
View File
@@ -0,0 +1,138 @@
/*
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.
*/
/**
* EXPERIMENTAL
*
* What a host needs in order to talk about Element Call, without Element Call:
* the component's props and the types they are made of, the handle and the
* host bridge, the intents a call can be started with and the configuration
* each implies.
*
* This is an entry point of its own, `@element-hq/element-call-component/api`,
* so that a host can import it on a path that must stay light — a call model
* that needs `BackgroundStyle.Solid` as a value, say — and load the component
* itself, and everything it brings, only when a call is shown. The main entry
* point re-exports all of it, so a host that does not care may import
* everything from there.
*
* Nothing in here may reach anything that is not a type or a constant: the
* point of this module is what it does not import.
*/
import type { Ref } from "react";
import type { MatrixClient } from "matrix-js-sdk";
import type { UrlProperties } from "../src/UrlParams";
import type { ElementCallHandle, ElementCallHostBridge } from "./host";
import {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
} from "../src/UrlConfiguration";
// How the host and Element Call talk to each other, and what they say
export type { ElementCallHandle, ElementCallHostBridge } from "./host";
export type { DeviceMuteRequest, DeviceMuteState } from "../src/HostBridge";
export type { JoinCallData } from "../src/widget";
// The deployment-wide configuration, as distinct from ElementCallConfiguration
// below, which is per call
export type { ConfigOptions } from "../src/config/ConfigOptions";
// The values that appear in ElementCallConfiguration and in the intent, and
// what each intent means by default
export {
BackgroundStyle,
configurationForIntent,
HeaderStyle,
type UrlConfiguration,
UserIntent,
};
/**
* How Element Call should behave. Everything is optional; anything left out
* takes the default that {@link ElementCallProps.intent} implies.
*
* This is the behaviour a widget can be configured with through its URL, plus
* the one fact about the call a host has a say in here, the background. The
* rest of what a widget's URL carries — who the user is, how to reach the
* homeserver, where to report analytics, the shared secret of a room that is
* encrypted with one — a component host supplies by other routes, or not at
* all; and what can change while the call is running, the theme and the
* language, is a prop of its own.
*/
export type ElementCallConfiguration = Partial<UrlConfiguration> &
Partial<Pick<UrlProperties, "background">>;
export interface ElementCallProps {
/**
* 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;
/**
* 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
* Call decides what each of those means: whether to show the lobby first,
* whether to ring, and so on.
*
* Defaults to joining an existing group call, which is the most conservative
* reading, but a host that knows which button the user pressed should say so.
*/
intent?: UserIntent;
/**
* How Element Call should behave, overriding whatever {@link intent} implies.
* A host that finds itself setting a lot of these probably wants a different
* intent instead.
*
* Compared by value, so it is fine to write this inline; only a change to
* what it says restarts anything.
*/
config?: ElementCallConfiguration;
/**
* What Element Call tells the host while the call is running: that the user
* has joined or hung up, that it would like to be kept on screen, and so on.
* Without one, Element Call assumes nobody is listening.
*/
hostBridge?: ElementCallHostBridge;
/**
* What the host tells Element Call: to hang up, to mute, to join. Available
* once the component has rendered.
*/
ref?: Ref<ElementCallHandle>;
/**
* The theme to show Element Call in, `light` or `dark`. Left out, Element
* Call picks. Changes take effect at once, and cost nothing else.
*/
theme?: string;
/**
* The language to show Element Call in, as a BCP 47 tag: one of the
* `supportedLanguages` the main entry point exports, or something that falls
* back to one (`de-AT` to `de`). Left out, the browser's language is used.
*
* Translations are one thing shared by every Element Call on the page, so
* the most recently set language wins for all of them.
*/
language?: string;
/**
* Where the parts of Element Call that float above it — its menus and
* tooltips, which Compound portals out of the call — are rendered. Left out,
* they go into the host document's body, as they do for any other component
* on the host's page.
*
* A host that moves Element Call's DOM into another document, such as a
* Document Picture-in-Picture window, passes that document's body here so
* that a menu opens in the window its button is in. Changes take effect at
* once. `null` means the root is not available yet, as from a callback ref
* that has not fired: menus and tooltips wait for it rather than falling
* back to the body.
*/
portalRoot?: HTMLElement | null;
}
+112
View File
@@ -0,0 +1,112 @@
/*
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 postcss from "postcss";
import { ROOT_SELECTOR, scopeStylesToRoot } from "./scopeStylesToRoot";
const inRoot = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
const isRoot = `:where(${ROOT_SELECTOR})`;
async function scope(css: string, file = "base.css"): Promise<string> {
const result = await postcss([scopeStylesToRoot()]).process(css, {
from: file,
});
return result.css;
}
describe("scopeStylesToRoot", () => {
it("makes the root stand in for the document", async () => {
expect(await scope("html { line-height: 1.15 }")).toBe(
`${isRoot} { line-height: 1.15 }`,
);
expect(await scope("body { margin: 0 }")).toBe(`${isRoot} { margin: 0 }`);
expect(await scope(":root { --a: 1 }")).toBe(`${isRoot} { --a: 1 }`);
expect(await scope("body.no-scroll-body { position: fixed }")).toBe(
`${isRoot}.no-scroll-body { position: fixed }`,
);
expect(await scope("body .x { color: red }")).toBe(
`${isRoot} .x { color: red }`,
);
});
it("collapses selectors that all became the root", async () => {
expect(await scope("html, body, input { font: inherit }")).toBe(
`${isRoot},input${inRoot} { font: inherit }`,
);
});
it("confines everything else to the root and what is inside it", async () => {
expect(await scope("h1 { margin: 0 }")).toBe(`h1${inRoot} { margin: 0 }`);
expect(await scope(".cpd-theme-dark { --a: 1 }")).toBe(
`.cpd-theme-dark${inRoot} { --a: 1 }`,
);
expect(await scope("* { box-sizing: border-box }")).toBe(
`*${inRoot} { box-sizing: border-box }`,
);
expect(await scope(".a > .b + .c { color: red }")).toBe(
`.a>.b+.c${inRoot} { color: red }`,
);
});
it("keeps pseudo-elements last", async () => {
expect(await scope("button::-moz-focus-inner { border: 0 }")).toBe(
`button${inRoot}::-moz-focus-inner { border: 0 }`,
);
expect(await scope(".a .b:hover::after { content: '' }")).toBe(
`.a .b:hover${inRoot}::after { content: '' }`,
);
expect(await scope("p:first-letter { color: red }")).toBe(
`p${inRoot}:first-letter { color: red }`,
);
});
it("leaves alone what already names the root", async () => {
const css = `${ROOT_SELECTOR}[data-platform="ios"] { --a: 1 }`;
expect(await scope(css)).toBe(css);
});
it("reaches into layers and media queries", async () => {
expect(
await scope(
"@layer normalize { h1 { margin: 0 } } @media (min-width: 1px) { p { margin: 0 } }",
),
).toBe(
`@layer normalize { h1${inRoot} { margin: 0 } } @media (min-width: 1px) { p${inRoot} { margin: 0 } }`,
);
});
it("does not touch keyframes or nested rules", async () => {
expect(
await scope("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }"),
).toBe("@keyframes spin { from { opacity: 0 } to { opacity: 1 } }");
expect(
await scope(
".a { color: red; &:hover { color: blue } .b { color: green } }",
),
).toBe(
`.a${inRoot} { color: red; &:hover { color: blue } .b { color: green } }`,
);
});
it("only touches the bare selectors of a CSS module", async () => {
const file = "Settings.module.css";
expect(await scope("pre { font-size: 1px }", file)).toBe(
`pre${inRoot} { font-size: 1px }`,
);
expect(await scope(".modal pre { font-size: 1px }", file)).toBe(
`.modal pre${inRoot} { font-size: 1px }`,
);
expect(await scope(".box_abc12 { border: 0 }", file)).toBe(
".box_abc12 { border: 0 }",
);
expect(await scope(".a .b_abc12:hover { border: 0 }", file)).toBe(
".a .b_abc12:hover { border: 0 }",
);
});
});
+162
View File
@@ -0,0 +1,162 @@
/*
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 AtRule,
type Container,
type Document,
type Plugin,
type Rule,
} from "postcss";
import selectorParser, {
type Node,
type Pseudo,
type Selector,
} from "postcss-selector-parser";
/**
* How the stylesheets find Element Call's root element. The attribute is put
* there by `useTheme`, on the container the host gives the component.
*/
export const ROOT_SELECTOR = "[data-element-call-root]";
// Both are `:where()`, which has no specificity of its own, so the rules keep
// exactly the weight they had before being scoped and nothing in Element Call's
// cascade changes — only where it applies.
//
// The root, or anything inside it. Appended to the element a rule is about,
// rather than prepended to the whole selector, so that a rule about the root
// itself (its theme class, say) still matches.
const IN_ROOT = `:where(${ROOT_SELECTOR}, ${ROOT_SELECTOR} *)`;
// The root itself, standing in for the document.
const IS_ROOT = `:where(${ROOT_SELECTOR})`;
/**
* Confines a stylesheet to Element Call's root element, for the build of
* Element Call as a component.
*
* As a page of its own, Element Call can style the document: normalize.css and
* its own base styles speak of `html`, `body` and bare elements, and its custom
* properties are declared on `:root`. As a component, all of that would land on
* the host's document too. This rewrites every selector so that it matches only
* the root or its descendants:
*
* - `html`, `body` and `:root` become the root element, which is what stands in
* for the document inside a host.
* - Everything else keeps its selector and gains `:where([data-element-call-root],
* [data-element-call-root] *)` on the element it styles.
* - Selectors that already name the root are left alone, as are keyframe
* selectors and rules nested inside another rule, which are relative to it.
*
* CSS modules are scoped by their class names already, so only their selectors
* that would match by element alone — `pre` rather than `.pre` — are touched.
*
* The root's custom properties are still inherited by everything inside it,
* the way they were from `:root`. Compound's own stylesheet — the design
* tokens, the component styles, the fonts — is not in this build at all: the
* host supplies it, unscoped, along with its copy of Compound.
*/
export function scopeStylesToRoot(): Plugin {
return {
postcssPlugin: "element-call-scope-styles-to-root",
Once(root) {
const isModule =
root.source?.input.file?.endsWith(".module.css") ?? false;
root.walkRules((rule) => {
if (isRelative(rule)) return;
rule.selector = (isModule ? scopeBare : scopeAll).processSync(
rule.selector,
{ lossless: false },
);
});
},
};
}
/** Whether a rule's selectors are relative to something other than the document. */
function isRelative(rule: Rule): boolean {
let parent: Container | Document | undefined = rule.parent;
while (parent !== undefined) {
if (parent.type === "rule") return true;
if (parent.type === "atrule") {
const { name } = parent as AtRule;
if (name.endsWith("keyframes") || name === "page") return true;
}
parent = parent.parent;
}
return false;
}
const processor = (isModule: boolean): ReturnType<typeof selectorParser> =>
selectorParser((selectors) => {
selectors.each((selector) => {
scopeSelector(selector, isModule);
});
// Mapping `html, body` onto the root leaves the same selector twice
const seen = new Set<string>();
selectors.each((selector) => {
const text = String(selector).trim();
if (seen.has(text)) selector.remove();
else seen.add(text);
});
});
// Everything, for stylesheets that speak of the document; only what a class
// does not already confine, for CSS modules
const scopeAll = processor(false);
const scopeBare = processor(true);
function scopeSelector(selector: Selector, isModule: boolean): void {
if (String(selector).includes(ROOT_SELECTOR)) return;
const compounds = splitCompounds(selector);
if (compounds.length === 0) return;
// Something said of the document is said of the root instead
const document = compounds[0].find(isDocumentSelector);
if (document !== undefined) {
document.replaceWith(pseudo(IS_ROOT));
return;
}
const subject = compounds.at(-1)!;
if (isModule && subject.some((node) => node.type === "class")) return;
// Pseudo-elements have to come last in a compound selector
const pseudoElement = subject.find(isPseudoElement);
if (pseudoElement === undefined) selector.append(pseudo(IN_ROOT));
else selector.insertBefore(pseudoElement, pseudo(IN_ROOT));
}
/** The compound selectors making up a complex selector, in order. */
function splitCompounds(selector: Selector): Node[][] {
const compounds: Node[][] = [[]];
for (const node of selector.nodes) {
if (node.type === "combinator") compounds.push([]);
else if (node.type !== "comment") compounds.at(-1)!.push(node);
}
return compounds.filter((compound) => compound.length > 0);
}
function isDocumentSelector(node: Node): boolean {
return (
(node.type === "tag" && (node.value === "html" || node.value === "body")) ||
(node.type === "pseudo" && node.value === ":root")
);
}
function isPseudoElement(node: Node): node is Pseudo {
if (node.type !== "pseudo") return false;
return (
node.value.startsWith("::") ||
[":before", ":after", ":first-line", ":first-letter"].includes(node.value)
);
}
function pseudo(text: string): Pseudo {
return selectorParser().astSync(text).nodes[0].nodes[0].clone() as Pseudo;
}
+46
View File
@@ -0,0 +1,46 @@
/*
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 ElementCallHostBridge } from "../index";
/**
* A host bridge that reports everything it is told, so that the harness can
* watch what Element Call says to its host. (What the host says to Element
* Call goes through the component's handle, and is logged by the pane.)
*/
export function createDevHostBridge(
log: (message: string) => void,
/** What the host does when Element Call asks to be closed. */
onClose: () => void,
): ElementCallHostBridge {
/**
* Records something Element Call told the host. Nothing is sent anywhere, so
* this is only asynchronous because a real host's answer would have to be.
*/
const told = async (message: string): Promise<void> => {
log(`→ ${message}`);
await Promise.resolve();
};
return {
setAlwaysOnScreen: async (alwaysOnScreen): Promise<void> =>
await told(`setAlwaysOnScreen(${alwaysOnScreen})`),
contentLoaded: async (): Promise<void> => await told("contentLoaded"),
notifyJoined: async (): Promise<void> => await told("notifyJoined"),
notifyHungUp: async (): Promise<void> => await told("notifyHungUp"),
notifyDeviceMute: async (state): Promise<void> =>
await told(
`notifyDeviceMute(audio: ${state.audio_enabled}, video: ${state.video_enabled})`,
),
// Present because this host really can dismiss Element Call, which is what
// makes it offer a close affordance at all
close: async (): Promise<void> => {
await told("close");
onClose();
},
};
}
+140
View File
@@ -0,0 +1,140 @@
/*
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.
*/
.credentials {
display: flex;
flex-direction: column;
gap: 8px;
max-inline-size: 420px;
margin: 48px auto;
padding: 24px;
background-color: #ffffff;
border: 1px solid #d4d4d8;
border-radius: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.error {
color: #b91c1c;
}
.harness {
display: grid;
grid-template-rows: auto 1fr auto;
block-size: 100%;
}
.header,
.paneBar {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border-block-end: 1px solid #d4d4d8;
background-color: #ffffff;
}
.header h1 {
font-size: 16px;
margin: 0;
}
.middle {
display: flex;
min-block-size: 0;
}
.sidebar {
flex: 0 0 220px;
padding: 12px;
border-inline-end: 1px solid #d4d4d8;
background-color: #ffffff;
overflow-y: auto;
}
.panes {
display: flex;
flex-wrap: wrap;
/* So that a pane is the size it was dragged to, rather than being stretched
to fill the row */
align-items: flex-start;
align-content: flex-start;
gap: 16px;
padding: 16px;
flex: 1;
min-inline-size: 0;
overflow: auto;
}
.pane {
display: flex;
flex-direction: column;
border: 1px solid #d4d4d8;
border-radius: 8px;
overflow: hidden;
background-color: #ffffff;
}
.paneBar {
flex-wrap: wrap;
gap: 6px;
border-block-end: none;
}
/* The space the host gives Element Call. Resizable so that the sizes it has to
cope with can be found by dragging rather than by rebuilding, and `overflow:
hidden` both to enable the resize handle and to show up anything inside Element
Call that does not fit the box it was given. */
.paneCall {
inline-size: 560px;
block-size: 420px;
min-inline-size: 180px;
min-block-size: 180px;
resize: both;
overflow: hidden;
}
.log {
max-block-size: 180px;
overflow-y: auto;
padding: 8px 12px;
border-block-start: 1px solid #d4d4d8;
background-color: #ffffff;
font-size: 12px;
}
.log h2 {
font-size: 13px;
margin: 0 0 4px;
}
.log ol {
margin: 0;
padding: 0;
list-style: none;
}
/* A host overlay, which Element Call must not be able to draw over */
.dialogScrim {
position: fixed;
inset: 0;
display: grid;
place-items: center;
background-color: rgb(0 0 0 / 50%);
z-index: 10;
}
.dialog {
padding: 24px;
border-radius: 8px;
background-color: #ffffff;
}
+380
View File
@@ -0,0 +1,380 @@
/*
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,
type FormEvent,
type ReactNode,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { type MatrixClient } from "matrix-js-sdk";
import { logger } from "matrix-js-sdk/lib/logger";
import {
ElementCall,
type ElementCallHandle,
supportedLanguages,
} from "../index";
import { createDevHostBridge } from "./DevHostBridge";
import { createSession, joinRoom } from "./session";
import styles from "./Harness.module.css";
interface Credentials {
homeserver: string;
username: string;
password: string;
room: string;
}
const CREDENTIALS_KEY = "element-call-component-harness";
const DEFAULT_CREDENTIALS: Credentials = {
homeserver: "https://synapse.m.localhost",
username: "",
password: "",
room: "",
};
/**
* The credentials to start with: the last ones used, so that a reload does not
* mean typing them again, overridden by anything in the query string.
*
* A host reading its own URL is entirely proper — it was Element Call doing so
* that was the mistake. It lets the end-to-end tests, or a shared link, say
* which account and room to use.
*/
function loadCredentials(): Credentials {
let stored: Partial<Credentials> = {};
try {
const json = localStorage.getItem(CREDENTIALS_KEY);
if (json !== null) stored = JSON.parse(json) as Credentials;
} catch (e) {
logger.warn("Could not read the stored harness credentials", e);
}
const query = new URLSearchParams(location.search);
const fromUrl = Object.fromEntries(
(["homeserver", "username", "password", "room"] as const)
.map((name) => [name, query.get(name)])
.filter(([, value]) => value !== null),
) as Partial<Credentials>;
return { ...DEFAULT_CREDENTIALS, ...stored, ...fromUrl };
}
interface Session {
label: string;
client: MatrixClient;
}
type State =
| { phase: "credentials" }
| { phase: "starting"; progress: string }
| { phase: "started"; roomId: string; sessions: Session[] }
| { phase: "failed"; error: string };
interface LogEntry {
pane: string;
message: string;
at: string;
}
/**
* One Element Call component, with the controls a host would have over it: the
* requests it can make of Element Call, and the ability to take it off screen
* altogether.
*/
const Pane: FC<{
session: Session;
roomId: string;
theme: string | undefined;
language: string | undefined;
log: (pane: string, message: string) => void;
}> = ({ session, roomId, theme, language, log }): ReactNode => {
const [mounted, setMounted] = useState(true);
const bridge = useMemo(
() =>
createDevHostBridge(
(message) => log(session.label, message),
() => setMounted(false),
),
[log, session.label],
);
// What the host asks of Element Call goes through the component's handle.
// Worth saying out loud when a request is refused — asking to hang up when
// there is no call, say — since that is the sort of thing the harness is for.
const handle = useRef<ElementCallHandle>(null);
const ask = (
name: string,
make: (handle: ElementCallHandle) => Promise<unknown>,
): void => {
if (handle.current === null) {
log(session.label, `← ${name}: not mounted`);
return;
}
log(session.label, `← ${name}`);
make(handle.current).then(
(reply) =>
log(
session.label,
`→ ${name} acknowledged${reply === undefined ? "" : `: ${JSON.stringify(reply)}`}`,
),
(e: unknown) => log(session.label, `→ ${name} refused: ${e}`),
);
};
return (
<section className={styles.pane} data-testid="call-pane">
<div className={styles.paneBar}>
<strong>{session.label}</strong>
<code>{session.client.getDeviceId()}</code>
<button onClick={(): void => setMounted((m) => !m)}>
{mounted ? "Unmount" : "Mount"}
</button>
<button
onClick={(): void =>
ask(
"setDeviceMute(audio: false)",
async (h) => await h.setDeviceMute({ audio_enabled: false }),
)
}
>
Mute
</button>
<button
onClick={(): void => ask("hangUp", async (h) => await h.hangUp())}
>
Hang up
</button>
</div>
{/* Resizable, because how Element Call copes with the size it is given is
one of the things we cannot find out from the standalone app */}
<div className={styles.paneCall} data-testid="call-container">
{mounted && (
<ElementCall
ref={handle}
client={session.client}
roomId={roomId}
hostBridge={bridge}
theme={theme}
language={language}
/>
)}
</div>
</section>
);
};
/** Host furniture, to make it visible if Element Call styles anything but itself. */
const HostChrome: FC = (): ReactNode => (
<nav className={styles.sidebar}>
<h2>Host chrome</h2>
<p>
This column belongs to the host. If Element Call&apos;s stylesheet reaches
outside its own container, it shows up here.
</p>
<hr />
<ul>
<li>Some room</li>
<li>Another room</li>
</ul>
<button>A host button</button>
</nav>
);
/**
* A dialog of the host's own, over the top of the calls. Element Call as a
* component has to sit underneath this — being unable to is one of the reasons
* for a component rather than an iframe.
*/
const HostDialog: FC<{ onClose: () => void }> = ({ onClose }): ReactNode => (
<div className={styles.dialogScrim}>
<div className={styles.dialog}>
<h2>A dialog belonging to the host</h2>
<p>This should cover the calls completely.</p>
<button onClick={onClose}>Close</button>
</div>
</div>
);
/**
* Stands in for a host application using the Element Call component: it owns the Matrix
* clients, the page and the space each call is given, and reaches Element Call
* only through the component's public interface.
*
* Two calls at once, from two devices of the same account, so that a real call
* happens between them and anything Element Call keeps once per process rather
* than once per call shows itself.
*/
export const Harness: FC = (): ReactNode => {
const [credentials, setCredentials] = useState(loadCredentials);
const [state, setState] = useState<State>({ phase: "credentials" });
const [entries, setEntries] = useState<LogEntry[]>([]);
const [dialogOpen, setDialogOpen] = useState(false);
// The host's language setting, which Element Call follows. Undefined means
// the host has none and Element Call uses the browser's.
const [language, setLanguage] = useState<string | undefined>(undefined);
const [theme, setTheme] = useState<string | undefined>(undefined);
const log = useCallback((pane: string, message: string): void => {
setEntries((entries) =>
[
...entries,
{ pane, message, at: new Date().toLocaleTimeString() },
].slice(-100),
);
}, []);
const start = useCallback(
(event: FormEvent): void => {
event.preventDefault();
localStorage.setItem(CREDENTIALS_KEY, JSON.stringify(credentials));
const { homeserver, username, password, room } = credentials;
const progress = (message: string): void =>
setState({ phase: "starting", progress: message });
progress("Starting");
void (async (): Promise<void> => {
try {
// One at a time: two logins at once from the same account is the
// shape of request homeservers rate limit
const sessions: Session[] = [];
for (const label of ["Call A", "Call B"])
sessions.push({
label,
client: await createSession(
homeserver,
username,
password,
(message) => progress(`${label}: ${message}`),
),
});
progress("Joining the room");
let roomId = room;
for (const { client } of sessions)
roomId = await joinRoom(client, roomId);
setState({ phase: "started", roomId, sessions });
} catch (e) {
logger.error("The harness could not start", e);
setState({ phase: "failed", error: `${e}` });
}
})();
},
[credentials],
);
const field = (
name: keyof Credentials,
label: string,
type = "text",
): ReactNode => (
<label className={styles.field}>
{label}
<input
type={type}
value={credentials[name]}
onChange={(e): void =>
setCredentials((c) => ({ ...c, [name]: e.target.value }))
}
/>
</label>
);
if (state.phase !== "started")
return (
<form className={styles.credentials} onSubmit={start}>
<h1>Element Call component harness</h1>
<p>
Signs in twice and shows the Element Call component twice, in a page
that is not Element Call&apos;s own.
</p>
{field("homeserver", "Homeserver")}
{field("username", "Username")}
{field("password", "Password", "password")}
{field("room", "Room ID or alias")}
<button type="submit" disabled={state.phase === "starting"}>
Start
</button>
{state.phase === "starting" && <p>{state.progress}</p>}
{state.phase === "failed" && (
<p className={styles.error}>{state.error}</p>
)}
</form>
);
return (
<div className={styles.harness}>
<header className={styles.header}>
<h1>Element Call component harness</h1>
<code>{state.roomId}</code>
<button onClick={(): void => setDialogOpen(true)}>
Open a host dialog
</button>
<label>
Theme{" "}
<select
value={theme ?? ""}
onChange={(e): void => setTheme(e.target.value || undefined)}
>
<option value="">Element Call&apos;s choice</option>
<option value="light">light</option>
<option value="dark">dark</option>
</select>
</label>
<label>
Language{" "}
<select
value={language ?? ""}
onChange={(e): void => setLanguage(e.target.value || undefined)}
>
<option value="">Browser default</option>
{supportedLanguages.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</select>
</label>
</header>
<div className={styles.middle}>
<HostChrome />
<main className={styles.panes}>
{state.sessions.map((session) => (
<Pane
key={session.label}
session={session}
roomId={state.roomId}
theme={theme}
language={language}
log={log}
/>
))}
</main>
</div>
<section className={styles.log} data-testid="bridge-log">
<h2>Host bridge</h2>
<ol>
{entries.map((entry, i) => (
<li key={i}>
<code>{entry.at}</code> <strong>{entry.pane}</strong>{" "}
{entry.message}
</li>
))}
</ol>
</section>
{dialogOpen && <HostDialog onClose={(): void => setDialogOpen(false)} />}
</div>
);
};
+23
View File
@@ -0,0 +1,23 @@
/*
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 host page's own styles. Deliberately plain, and deliberately not using
Element Call's design tokens: the harness should look like it does because of
this file, not because Element Call styled it. */
html,
body,
#root {
height: 100%;
}
body {
margin: 0;
font-family: system-ui, sans-serif;
background-color: #f4f4f5;
color: #18181b;
}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<!--
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.
-->
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Element Call component harness</title>
</head>
<body>
<!-- Deliberately plain: this page stands in for a host application, so
anything it looks like must have come from the host's own styles or from
Element Call reaching outside its container. -->
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
/*
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 { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { logger } from "matrix-js-sdk/lib/logger";
// What a host provides along with its copy of Compound: its stylesheet, the
// design tokens and the fonts they name. The component does not bundle them.
import "@fontsource/inter/400.css";
import "@fontsource/inter/500.css";
import "@fontsource/inter/600.css";
import "@fontsource/inter/700.css";
import "@fontsource/inconsolata/400.css";
import "@fontsource/inconsolata/700.css";
import "@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css";
import "@vector-im/compound-web/dist/style.css";
import { type ConfigOptions, initializeElementCall } from "../index";
import { Harness } from "./Harness";
// After Element Call's, so that the host has the last word on its own page
import "./host.css";
/**
* The development app's own `config.json`, so that the harness runs Element
* Call the way `pnpm dev` does. It is not in the repository — developers copy
* it from `config/config.devenv.json` — so its absence is expected rather than
* an error.
*/
async function loadConfig(): Promise<ConfigOptions> {
try {
const response = await fetch("/config.json");
if (response.ok) return (await response.json()) as ConfigOptions;
logger.warn(
`No config.json (${response.status}); running with Element Call's defaults`,
);
} catch (e) {
logger.warn("Could not read config.json", e);
}
return {};
}
await initializeElementCall(await loadConfig());
createRoot(document.getElementById("root")!).render(
<StrictMode>
<Harness />
</StrictMode>,
);
+79
View File
@@ -0,0 +1,79 @@
/*
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,
createClient,
type MatrixClient,
MemoryStore,
SyncState,
} from "matrix-js-sdk";
/**
* Logs in and brings up a client the way a host application would, so that the
* component is handed a real one rather than something Element Call built for
* itself.
*
* Everything is kept in memory and a fresh login happens on every reload. That
* costs a device on the development homeserver each time, which is harmless,
* and buys the harness two clients that cannot tread on each other's storage.
* Persisting the login to make reloads quicker would mean persisting the
* crypto store too: reusing a device ID with a fresh crypto store generates new
* device keys, and uploading them conflicts with the ones the server already
* holds.
*/
export async function createSession(
homeserver: string,
username: string,
password: string,
onProgress: (message: string) => void,
): Promise<MatrixClient> {
onProgress("Logging in");
const login = await createClient({ baseUrl: homeserver }).login(
"m.login.password",
{ identifier: { type: "m.id.user", user: username }, password },
);
const client = createClient({
baseUrl: homeserver,
accessToken: login.access_token,
userId: login.user_id,
deviceId: login.device_id,
store: new MemoryStore(),
useAuthorizationHeader: true,
fallbackICEServerAllowed: true,
});
onProgress(`Setting up crypto for ${login.device_id}`);
await client.initRustCrypto({ useIndexedDB: false });
onProgress(`Syncing ${login.device_id}`);
await client.startClient();
await new Promise<void>((resolve) => {
const onSync = (state: SyncState): void => {
if (state !== SyncState.Prepared && state !== SyncState.Syncing) return;
client.off(ClientEvent.Sync, onSync);
resolve();
};
client.on(ClientEvent.Sync, onSync);
});
return client;
}
/**
* The room to call in, joining it if this session is not in it yet — a host
* hands Element Call a room it already knows about, so the harness has to get
* itself into that position first.
*/
export async function joinRoom(
client: MatrixClient,
roomIdOrAlias: string,
): Promise<string> {
const room = await client.joinRoom(roomIdOrAlias);
return room.roomId;
}
+161
View File
@@ -0,0 +1,161 @@
/*
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 { renderHook } from "@testing-library/react";
import { createRef } from "react";
import { describe, expect, test, vi } from "vitest";
import {
type ElementCallHandle,
type ElementCallHostBridge,
useComponentHostBridge,
} from "./host";
describe("useComponentHostBridge", () => {
test("keeps one identity while the host supplies new objects", () => {
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: {} } },
);
const first = result.current;
rerender({ supplied: { notifyJoined: async () => {} } });
expect(result.current).toBe(first);
});
test("forwards to whatever the host most recently supplied", async () => {
const before = vi.fn().mockResolvedValue(undefined);
const after = vi.fn().mockResolvedValue(undefined);
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: { notifyJoined: before } } },
);
rerender({ supplied: { notifyJoined: after } });
await result.current.notifyJoined();
expect(before).not.toHaveBeenCalled();
expect(after).toHaveBeenCalledOnce();
});
test("is quiet about what the host did not implement", async () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined, undefined),
);
await expect(result.current.contentLoaded()).resolves.toBeUndefined();
await expect(
result.current.notifyDeviceMute({
audio_enabled: true,
video_enabled: false,
}),
).resolves.toBeUndefined();
expect(result.current.supportsReactions).toBe(true);
// Starting the user unmuted unasked is something a host has to opt into
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
});
test("lets the host allow joining unmuted on the intent", () => {
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: {} } },
);
expect(result.current.allowJoinUnmutedViaIntent).toBe(false);
// Read through to whatever the host most recently said
rerender({ supplied: { allowJoinUnmutedViaIntent: true } });
expect(result.current.allowJoinUnmutedViaIntent).toBe(true);
});
test("only has a close when the host has one, since that is a signal", () => {
const { result, rerender } = renderHook(
({ supplied }: { supplied: ElementCallHostBridge }) =>
useComponentHostBridge(supplied, undefined, undefined),
{ initialProps: { supplied: {} } },
);
expect(result.current.close).toBeUndefined();
const close = vi.fn().mockResolvedValue(undefined);
rerender({ supplied: { close } });
expect(result.current.close).toBeDefined();
});
test("never offers profile changes, since the account is the host's", () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined, undefined),
);
expect(result.current.supportsProfileChanges).toBe(false);
});
describe("the handle", () => {
test("delivers a request to what is listening and resolves on its reply", async () => {
const ref = createRef<ElementCallHandle>();
const { result } = renderHook(() =>
useComponentHostBridge(undefined, ref, undefined),
);
const received = vi.fn();
result.current.deviceMute$.subscribe(({ data, reply }) => {
received(data);
reply({ audio_enabled: data.audio_enabled!, video_enabled: true });
});
await expect(
ref.current!.setDeviceMute({ audio_enabled: false }),
).resolves.toEqual({ audio_enabled: false, video_enabled: true });
expect(received).toHaveBeenCalledWith({ audio_enabled: false });
});
test("refuses a request nothing in Element Call is listening for", async () => {
const ref = createRef<ElementCallHandle>();
renderHook(() => useComponentHostBridge(undefined, ref, undefined));
await expect(ref.current!.hangUp()).rejects.toThrow(
"Nothing in Element Call can hang up right now",
);
});
});
describe("the theme", () => {
test("reaches a subscriber that arrives after it was set", () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined, "light"),
);
const names: (string | undefined)[] = [];
result.current.themeChange$.subscribe(({ data }) =>
names.push(data.name),
);
expect(names).toEqual(["light"]);
});
test("follows the prop", () => {
const { result, rerender } = renderHook(
({ theme }: { theme: string | undefined }) =>
useComponentHostBridge(undefined, undefined, theme),
{ initialProps: { theme: "light" } },
);
const names: (string | undefined)[] = [];
result.current.themeChange$.subscribe(({ data }) =>
names.push(data.name),
);
rerender({ theme: "dark" });
expect(names).toEqual(["light", "dark"]);
});
test("says nothing when the host leaves the theme to Element Call", () => {
const { result } = renderHook(() =>
useComponentHostBridge(undefined, undefined, undefined),
);
const names: (string | undefined)[] = [];
result.current.themeChange$.subscribe(({ data }) =>
names.push(data.name),
);
expect(names).toEqual([]);
});
});
});
+201
View File
@@ -0,0 +1,201 @@
/*
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.
*/
/**
* How a host application and the Element Call component talk to each other.
*
* Inside Element Call the host is a {@link HostBridge}, which carries the
* host's requests as rxjs observables because that is what the widget API and
* the view models work in. A host should not have to know about rxjs, or agree
* with us on a version of it, so a component host sees neither: it implements
* plain async callbacks for what Element Call tells it, and makes its own
* requests through an imperative handle on the component, the way it would
* call `play()` on a video element. This module adapts the one to the other.
*/
import { type Ref, useEffect, useImperativeHandle } from "react";
import { ReplaySubject, Subject } from "rxjs";
import {
type DeviceMuteRequest,
type DeviceMuteState,
type HostBridge,
type HostRequest,
} from "../src/HostBridge";
import { type JoinCallData } from "../src/widget";
import { useInitial } from "../src/useInitial";
import { useLatest } from "../src/useLatest";
/**
* What Element Call tells the application hosting it as a component.
* Everything is optional: a host implements what it wants to hear about.
*
* Compared by nothing — Element Call always calls whichever one it was most
* recently given, so this may be written inline.
*/
export interface ElementCallHostBridge {
/**
* Asks the host to keep Element Call on screen (or stop doing so), so that a
* call in progress is not torn down when the user navigates elsewhere.
*/
setAlwaysOnScreen?(alwaysOnScreen: boolean): Promise<void>;
/** Tells the host that Element Call has finished loading. */
contentLoaded?(): Promise<void>;
/** Tells the host that the user has joined the call. */
notifyJoined?(): Promise<void>;
/** Tells the host that the user has hung up. */
notifyHungUp?(): Promise<void>;
/** Tells the host the user's current audio and video mute state. */
notifyDeviceMute?(state: DeviceMuteState): Promise<void>;
/**
* Asks the host to close Element Call: to unmount the component. Its
* presence is what makes Element Call offer a close button on its error
* screens, and leave the host to decide what is shown once a call has ended.
* Without it, Element Call shows its own post-call screen, if it has one for
* the situation, or nothing.
*/
close?(): Promise<void>;
/**
* Whether Element Call may send and receive reactions in this room.
* Defaults to true.
*/
readonly supportsReactions?: boolean;
/**
* Whether the user may start unmuted when the intent skips the lobby, so
* that they never see their devices before joining. Defaults to false: the
* user starts muted and unmutes themselves. A host that chose the intent on
* the user's behalf, and is sure they expect to be heard and seen at once,
* says so here — as a Matrix client hosting Element Call as a widget does.
*/
readonly allowJoinUnmutedViaIntent?: boolean;
}
/**
* What a host can ask of a mounted Element Call, reached through the
* component's `ref`. Each request resolves once Element Call has acted on it,
* and rejects if nothing in Element Call is in a position to act: hanging up
* when there is no call, say.
*/
export interface ElementCallHandle {
/**
* Joins the call, when Element Call was configured to `preload` and is
* waiting to be told to. Says which devices to join with.
*/
join(devices: JoinCallData): Promise<void>;
/** Leaves the call. */
hangUp(): Promise<void>;
/**
* Changes the mute state, for whichever of audio and video is given, and
* reports the state that results.
*/
setDeviceMute(request: DeviceMuteRequest): Promise<DeviceMuteState>;
}
/** Hands a request to Element Call and waits for it to be acknowledged. */
async function request<Data, Reply>(
listeners: Subject<HostRequest<Data, Reply>>,
what: string,
data: Data,
): Promise<Reply> {
if (!listeners.observed)
throw new Error(`Nothing in Element Call can ${what} right now`);
return await new Promise((resolve) =>
listeners.next({ data, reply: resolve }),
);
}
/**
* The {@link HostBridge} the rest of Element Call sees, built from what a
* component host supplies and wired to the handle it is given.
*
* The bridge is created once and never changes identity — everything that
* depends on it would otherwise restart when the host re-rendered with a new
* object — and forwards each call to whatever the host most recently passed.
*/
export function useComponentHostBridge(
supplied: ElementCallHostBridge | undefined,
ref: Ref<ElementCallHandle> | undefined,
/** The theme the host wants, or undefined to leave it to Element Call. */
theme: string | undefined,
): HostBridge {
const latest = useLatest(supplied ?? {});
const requests = useInitial(() => ({
// The theme is state, not an event: a `theme` prop rather than a request
// on the handle. It travels this channel because that is how the rest of
// Element Call hears about a host's theme, and replays so that whatever
// subscribes after the host has set it — everything, on first render —
// still hears the current one.
themeChange$: new ReplaySubject<HostRequest<{ name?: string }>>(1),
join$: new Subject<HostRequest<JoinCallData>>(),
hangUp$: new Subject<HostRequest<Record<string, never>>>(),
deviceMute$: new Subject<HostRequest<DeviceMuteRequest, DeviceMuteState>>(),
}));
useEffect(() => {
if (theme !== undefined)
requests.themeChange$.next({ data: { name: theme }, reply: () => {} });
}, [requests, theme]);
const bridge = useInitial((): HostBridge => ({
setAlwaysOnScreen: async (alwaysOnScreen) => {
await latest.current.setAlwaysOnScreen?.(alwaysOnScreen);
},
contentLoaded: async () => {
await latest.current.contentLoaded?.();
},
notifyJoined: async () => {
await latest.current.notifyJoined?.();
},
notifyHungUp: async () => {
await latest.current.notifyHungUp?.();
},
notifyDeviceMute: async (state) => {
await latest.current.notifyDeviceMute?.(state);
},
// Whether these exist is itself information, so they are read through
// rather than wrapped unconditionally
get close() {
const close = latest.current.close;
return close === undefined
? undefined
: async (): Promise<void> => await close();
},
// Not offered to a component host: the client it hands over holds the
// credentials to fetch media itself. A widget's client does not, which
// is what the internal bridge's `downloadMedia` is for.
get supportsReactions(): boolean {
return latest.current.supportsReactions ?? true;
},
get allowJoinUnmutedViaIntent(): boolean {
return latest.current.allowJoinUnmutedViaIntent ?? false;
},
// Whatever the host says or does not say, the account is its own: it
// signed the user in and handed us the client. So Element Call never
// offers to edit the profile from inside a component.
supportsProfileChanges: false,
...requests,
}));
useImperativeHandle(
ref,
(): ElementCallHandle => ({
join: async (devices) =>
await request(requests.join$, "join a call", devices),
hangUp: async () => await request(requests.hangUp$, "hang up", {}),
setDeviceMute: async (muteRequest) =>
await request(
requests.deviceMute$,
"change the mute state",
muteRequest,
),
}),
[requests],
);
return bridge;
}
+282
View File
@@ -0,0 +1,282 @@
/*
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.
*/
/**
* EXPERIMENTAL
*
* Element Call as a React component, for an application that wants to show a
* call inside itself rather than in an iframe.
*
* The host supplies the client and says which room to call in; Element Call
* supplies the call. Everything it would otherwise take from the page it is on
* — the URL, the document body, a Matrix session of its own — comes from the
* host instead, or is confined to the container it is mounted in.
*/
// The element defaults and custom properties every Element Call stylesheet
// builds on. Written for a page, they speak of `html`, `body` and bare
// elements; the component build confines them, and every other stylesheet in
// this bundle, to the root element below (see build/scopeStylesToRoot.ts), so
// that the host's document is left as it was.
//
// Compound is not in here. The component uses the host's copy of it (a peer
// dependency, left external by the build), and the host loads Compound's
// stylesheet, design tokens and fonts along with it: what Compound floats into
// the host's body — tooltips, menus — is then styled by the same stylesheet as
// everything else there, and Element Call's root takes the tokens and fonts of
// the theme class it carries, as any element on the host's page would.
//
// Where these land relative to the component stylesheets is the bundler's
// choice — the standalone app puts them first, this build puts them in the
// middle — so nothing in base.css may depend on winning or losing against a
// component's own rules at equal specificity. It currently does not: what it
// declares unlayered is custom properties on Element Call's root, which
// components inherit rather than compete with.
import "../src/base.css";
import {
type FC,
type JSX,
type ReactNode,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { logger } from "matrix-js-sdk/lib/logger";
import { I18nextProvider } from "react-i18next";
import { TooltipProvider } from "@vector-im/compound-web";
import { ErrorBoundary } from "@sentry/react";
import { shouldPolyfill as shouldPolyfillSegmenter } from "@formatjs/intl-segmenter/should-polyfill";
import { shouldPolyfill as shouldPolyfillDurationFormat } from "@formatjs/intl-durationformat/should-polyfill.js";
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 {
configurationForIntent,
componentProperties,
type UrlParams,
UrlParamsProvider,
UserIntent,
useUrlParams,
} from "../src/UrlParams";
import { MediaDevicesContext } from "../src/MediaDevicesContext";
import { MediaDevices } from "../src/state/MediaDevices";
import { ObservableScope } from "../src/state/ObservableScope";
import { ProcessorProvider } from "../src/tracks/TrackProcessorContext";
import { Config } from "../src/config/Config";
import { type ConfigOptions } from "../src/config/ConfigOptions";
import { i18n } from "../src/utils/i18n";
import { useTheme } from "../src/useTheme";
import { useStableValue } from "../src/useStableValue";
import styles from "./ElementCall.module.css";
import { useComponentHostBridge } from "./host";
import { CallPortalRoot } from "./CallPortalRoot";
import { type ElementCallProps } from "./api";
import { supportedLanguages, translationsBackend } from "./localization";
// The languages Element Call can be shown in
export { supportedLanguages } from "./localization";
// Everything a host needs to talk about Element Call — the props, the handle,
// the host bridge, the intents and what each means — also available without
// the component itself from `@element-hq/element-call-component/api`
export * from "./api";
/**
* 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 async function initializeElementCall(
config: ConfigOptions = {},
): Promise<void> {
const polyfills: Promise<unknown>[] = [];
if (shouldPolyfillSegmenter())
polyfills.push(import("@formatjs/intl-segmenter/polyfill-force"));
if (shouldPolyfillDurationFormat())
polyfills.push(import("@formatjs/intl-durationformat/polyfill-force.js"));
await Promise.all(polyfills);
Config.initWith(config);
await i18n
.use(translationsBackend)
.use(new LanguageDetector())
.init({
fallbackLng: "en",
defaultNS: "app",
keySeparator: ".",
nsSeparator: false,
pluralSeparator: "_",
contextSeparator: "|",
supportedLngs: [...supportedLanguages],
interpolation: { escapeValue: false },
// English is bundled in, so the fallback never has to be loaded; every
// other language arrives from the backend when first asked for.
partialBundledLanguages: true,
resources: { en: { app: EN } },
detection: {
// The browser's language, until the host says otherwise through the
// `language` prop. Nothing is remembered: the choice is the host's.
order: ["navigator"],
caches: [],
},
});
}
/** Applies the theme and background to the container, before it is painted. */
const Decoration: FC<{ children: JSX.Element }> = ({ children }) => {
useTheme();
const { background } = useUrlParams();
const rootElement = useRootElement();
useLayoutEffect(() => {
rootElement.setAttribute("data-background", background);
}, [rootElement, background]);
return children;
};
export const ElementCall: FC<ElementCallProps> = ({
client,
roomId,
intent = UserIntent.JoinExistingCall,
config,
hostBridge: suppliedHostBridge,
ref,
theme,
language,
portalRoot,
}): ReactNode => {
const hostBridge = useComponentHostBridge(suppliedHostBridge, ref, theme);
useEffect(() => {
if (language !== undefined)
i18n
.changeLanguage(language)
.catch((e) => logger.error(`Could not switch to ${language}`, e));
}, [language]);
// The container is what Element Call decorates and portals into, so nothing
// inside can render until we have it.
const [container, setContainer] = useState<HTMLDivElement | null>(null);
// Element Call has no URL of its own to read any of this from, and the
// host's URL is not Element Call's business, so the defaults come from the
// intent with the host's wishes over the top.
//
// Everything downstream — the mute state, the call view model and with it
// the media connection — is keyed on the identity of this object, so it has
// to be stable for as long as its contents are. A host writing `config`
// inline would otherwise tear the call down on every render.
const stableConfig = useStableValue(config);
const params = useMemo(
(): UrlParams => ({
...componentProperties,
roomId,
...configurationForIntent(intent),
...stableConfig,
}),
[roomId, intent, stableConfig],
);
// Created in an effect so that the scope it lives in ends when the component
// is unmounted (or these options change), rather than keeping its device
// observers running for the rest of the page's life. Null until then, which
// is one render.
const { controlledAudioDevices, callIntent } = params;
const [mediaDevices, setMediaDevices] = useState<MediaDevices | null>(null);
useEffect(() => {
const scope = new ObservableScope();
setMediaDevices(
new MediaDevices(scope, { controlledAudioDevices, callIntent }),
);
return (): void => {
setMediaDevices(null);
scope.end();
};
}, [controlledAudioDevices, callIntent]);
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 && rtcSession !== null && mediaDevices !== null;
const announcedLoaded = useRef(false);
useEffect(() => {
if (!ready || announcedLoaded.current) return;
announcedLoaded.current = true;
hostBridge
.contentLoaded()
.catch((e) => logger.error("Could not tell the host we had loaded", e));
}, [ready, hostBridge]);
return (
<I18nextProvider i18n={i18n}>
<HostBridgeProvider value={hostBridge}>
<UrlParamsProvider value={params}>
<div ref={setContainer} className={styles.root}>
{ready && (
// Stands in for the standalone page's `#root`: see `.content`
// in ElementCall.module.css
<div className={styles.content}>
<CallPortalRoot root={portalRoot}>
<RootElementProvider value={container}>
{/* Whatever goes wrong in here is shown in here. Left to
propagate, an error would unmount the host's own tree. */}
<ErrorBoundary
fallback={(error) => <ErrorPage error={error} />}
// A broken call should not hold the host on screen
onError={() => void hostBridge.setAlwaysOnScreen(false)}
>
<Decoration>
<TooltipProvider>
<ClientProvider client={client}>
<MediaDevicesContext value={mediaDevices}>
<ProcessorProvider>
<CallView
client={client}
rtcSession={rtcSession}
isPasswordlessUser={false}
confineToRoom={params.confineToRoom}
preload={params.preload}
skipLobby={params.skipLobby}
/>
</ProcessorProvider>
</MediaDevicesContext>
</ClientProvider>
</TooltipProvider>
</Decoration>
</ErrorBoundary>
</RootElementProvider>
</CallPortalRoot>
</div>
)}
</div>
</UrlParamsProvider>
</HostBridgeProvider>
</I18nextProvider>
);
};
+46
View File
@@ -0,0 +1,46 @@
/*
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 { supportedLanguages, translationsBackend } from "./localization";
const read = async (
language: string,
namespace = "app",
): Promise<Record<string, unknown>> =>
await new Promise((resolve, reject) =>
translationsBackend.read(language, namespace, (error, data) => {
if (error) reject(error);
else resolve(data as Record<string, unknown>);
}),
);
describe("component translations", () => {
test("offer every language in locales/, tagged as its directory is", () => {
expect(supportedLanguages).toContain("en");
expect(supportedLanguages).toContain("de");
expect(supportedLanguages).toContain("zh-Hans");
expect(new Set(supportedLanguages).size).toBe(supportedLanguages.length);
});
test("load a language's translations on demand", async () => {
const de = await read("de");
expect(de).toHaveProperty("action");
expect(de).not.toEqual(await read("en"));
});
test("refuse a language there are no translations for", async () => {
await expect(read("xx")).rejects.toThrow("No app translations for xx");
});
test("refuse a namespace there are no translations for", async () => {
await expect(read("en", "other")).rejects.toThrow(
"No other translations for en",
);
});
});
+55
View File
@@ -0,0 +1,55 @@
/*
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.
*/
/**
* Translations for Element Call as a component.
*
* The standalone app fetches its locale files at runtime from URLs its own
* build emits, which a host serving the library from somewhere else could not
* resolve. The component instead has the bundler split every locale into a
* chunk of its own, loaded the first time its language is asked for; English,
* the fallback, is bundled in so that the first paint never waits for it.
*/
import { type BackendModule, type ResourceKey } from "i18next";
import { languageOfLocalePath } from "../src/utils/i18n";
/** Every locale, as a lazily imported module. */
const translations = import.meta.glob<{ default: ResourceKey }>(
"../locales/*/app.json",
);
/**
* The languages Element Call can be shown in, as BCP 47 tags — `en`, `de`,
* `zh-Hans` and so on. A language that is not one of these falls back to its
* base language where there is one (`de-AT` to `de`), and to English otherwise.
*/
export const supportedLanguages: readonly string[] = [
...new Set(Object.keys(translations).map(languageOfLocalePath)),
];
/** Loads translations on demand. */
export const translationsBackend: BackendModule = {
type: "backend",
init(): void {},
read(language: string, namespace: string, callback): void {
const load = translations[`../locales/${language}/${namespace}.json`];
if (load === undefined) {
callback(new Error(`No ${namespace} translations for ${language}`), null);
return;
}
load().then(
(module) => callback(null, module.default),
(error: unknown) =>
callback(
error instanceof Error ? error : new Error(String(error)),
null,
),
);
},
};

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