Skip to content

Feat/persistant chat - #41657

Draft
rodrigok wants to merge 58 commits into
developfrom
feat/persistant-chat
Draft

Feat/persistant chat#41657
rodrigok wants to merge 58 commits into
developfrom
feat/persistant-chat

Conversation

@rodrigok

@rodrigok rodrigok commented Aug 2, 2026

Copy link
Copy Markdown
Member

Proposed changes (including videos or screenshots)

Issue(s)

Steps to test or reproduce

Further comments

Review in cubic

rodrigok and others added 12 commits August 1, 2026 13:29
… bar

Move the conference chrome from a vertical side rail to a control bar along the
bottom of the call — the position third-party providers put their own toolbar in,
so an embedded provider and the future native conference read the same.

`SideRail`/`SideRailPanel` are renamed and split into `CallBar` (+ `CallBarActions`,
`CallBarAction`) and `CallPanel`, so both names describe their position and can be
reused by the native video conference.

The page is now a column — a row holding the call and the panel, then the bar — so
`CallPanel` is a sibling of the call area rather than a child of the bar. Toggling
the chat animates its own width without ever reflowing the bar, by construction
instead of by careful sizing. The panel moves to the inline end to match the chat
action, which puts its close button at the far end of its header.

`CallBarActions placement='end'` is taken out of flow and anchored to the inline
end, so adding or removing end actions never pulls the centred controls
off-centre — the shape the native conference needs once it owns mic/camera/
screen-share/hang-up.

Add `useProviderCallBridge`, letting a provider that renders its own toolbar hide
ours and drive the chat panel (`set-call-bar-visible`, `set-chat-visible`,
`toggle-chat`) so the user never sees two competing sets of controls. The iframe is
cross-origin, so `event.origin` cannot be allow-listed against our own origin;
instead every message must come from the exact window we embedded, which no other
frame or tab can forge. Unknown commands and malformed payloads are rejected.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A call belongs in its own window rather than a tab in the user's strip — it
mirrors what the desktop app already does with its dedicated video window, and
keeps the call visible while the user works in the main app.

`useVideoConfOpenCall` now tries a popout (1280x800, capped to the available
screen and centred) and falls back to an ordinary tab when it is refused; some
browsers and extensions block popup-shaped windows while still allowing a plain
one. Only when both are blocked does `VideoConfBlockModal` ask the user to allow
it. A window that comes back already closed counts as blocked too.

`noopener` is deliberately absent from the features string: the conference page
posts navigation requests back to its opener, and `noopener` would both sever
that link and make `window.open` return null — indistinguishable from a blocked
popup, so the popout would silently degrade to a tab forever. There is a test
pinning that.

Reusing the shared conference window is unchanged, and now covered: already
showing this conference focuses it without features, so a window the user has
arranged is not resized or recentred; a different conference navigates it; a
closed one is reopened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…loading

`UsernameCheck` shows `HomeSkeleton` — a sidebar list, room and composer skeleton
— while it resolves the user. It sits in the shared authentication chain, so the
conference, which renders standalone, was flashing a whole fake app shell it
never goes on to show.

This is the same shape of problem as the navigation chrome itself: the chrome
moved to `MainLayout`, but its loading counterpart stayed behind in the auth
chain. `AuthenticationCheck` and `UsernameCheck` now take an optional `loading`
node. It still defaults to `HomeSkeleton`, so no existing route changes, and the
conference route passes `PageLoading` instead — which also matches what the
conference shows while joining, making startup one continuous state rather than a
skeleton followed by a spinner.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chat panel is 400px, but it renders the full room UI, which is spaced for the
main app. The default message start padding plus the avatar gutter's left margin
spent more of that width on empty space than the panel can spare, and the
composer sat flush against the edges — opting into the embedded layout zeroes its
inline padding, which is sized for the tiny `?layout=embedded` iframe where every
pixel counts.

`narrowRoomStyle` restores the composer's padding and trims the message start
padding and gutter margin, scoped to this subtree so the room's normal full-width
appearance and every external embed are untouched. Only the start padding is
trimmed; the message toolbar and timestamp column sit against the end padding and
need the room. The panel header is condensed to match.

The call iframe is named with `aria-label` rather than `title`: a `title` on a
full-viewport iframe also renders as a hover tooltip, floating a label over the
call for as long as the pointer is inside it.

Also documents the popout ladder and the standalone loading placeholder from the
two preceding commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Documents the verified add-participants behaviour per room type, and the agreed
design for separating "being in the call" from "being able to read the chat".

Adding someone to a conference currently puts them in a room — the conference's
room, or a fresh discussion — and join authorization is then derived from room
membership. That conflates two different things. The planned model makes the
existing `users[]` the membership list with a per-entry `joined` flag, authorizes
on room access *or* conference membership, rings the added users, and surfaces
members who can't see the chat with a remedy instead of forcing that choice
up-front.

The section is fenced as planned-not-implemented and doubles as the progress
tracker. It records the decisions behind the design, and the traps each phase has
to clear — notably that `addUserById` uses `$addToSet` with a whole document (so
marking a member joined later would append a duplicate rather than update), and
that every `users[]` entry already stored predates the flag and represents
someone who joined, so a reader filtering on it would show historical conferences
as empty.

Also documents the verified per-room-type matrix for the current flow, including
the divergence where a repeated "don't keep history" add rebuilds from the
original room and drops earlier invitees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adding someone to a conference used to put them in a room — either the
conference's room, keeping its history, or a fresh discussion — because joining
was authorized purely from room membership. That conflated being in the call with
being able to read its chat.

`users[]` becomes the conference's membership list, with a per-entry `joined` flag
and `joinedAt`; `ts` keeps meaning "became a member". `join`/`info` now authorize
on conference membership *or* access to the conference's room or discussion, so
someone added from outside the room can join the call without being handed the
room's history. `add-participants` registers members, touches no room, drops
`keepHistory`, and returns the ids it added.

Two traps this had to clear. `addUserById` used `$addToSet` with a whole
document, which compares entire objects — once an entry can be mutated, a second
write would append a duplicate instead of updating. It is replaced by
`addMemberById` (a `$push` guarded on `users._id` in the query, atomic and
idempotent, which also removes a racy in-memory dedup) and `setUserJoinedById`
(`$set` through `arrayFilters`). And every `users[]` entry already stored predates
the flag while representing someone who joined, so `hasJoinedVideoConference`
reads an absent flag as joined and is the only place that decides — no reader
tests the field, and no backfill or deploy ordering is required.

Every reader that means "who is in the call" now filters through that helper: the
message block's counts, name list, avatar stacks and empty state, the call history
list's avatars, and the direct-call ring-stop check. The `video-conference`
stream's `allowRead` deliberately keeps using full membership, so someone added
can follow `discussionUpdated` before joining. `assignDiscussionToConference` now
subscribes the union of the conference's members and the original room's members,
rather than only those who had joined.

Ringing is decided per call event against the list being rung, via
`shouldRingVideoConference`. Starting a call rings the room, so a large room still
rings nobody; an add rings only the people added and is capped at the same limit,
so an add always rings.

This also makes server-originated ringing work at all. The server has long set
`ringing: true` on group conferences and broadcast a `ring` action, but no client
ever handled it — the only match for `'ring'` in the client was a word in the
E2EE wordlist. Real DM ringing is driven client-side by the caller repeating
`call` on an interval. `VideoConfManager` now handles `ring`, which means group
conferences that were silently not ringing will start to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Declining a conference is now stored on the member's `users[]` entry as `declined`
/ `declinedAt`, through `POST /v1/video-conference.decline`.

The endpoint takes no target user and writes only against the caller's own
membership, so declining can never be a way to end someone else's conference. The
client-published `rejected` message stays, because the 1:1 flow depends on it —
the caller's client is waiting on it to stop dialling — but it is a claim one
client makes about another user's call, so it is no longer what gets stored.

Someone rung as a member of the conference's room has no entry yet, so declining
creates one; without it there is nowhere to record the decline. Declining is not
exclusive with joining — a member can decline and then join later.

Adds a regression test pinning that a rejection for a call we are not placing
never reaches `video-conference.cancel`. That currently holds only because
`onDirectCallRejected` bails when the callId doesn't match `currentCallData`, and
the teardown sits behind that same guard, so widening it would silently let one
person's decline end everybody's call.

Also extracts `mapVideoConfUserFromApi`: membership now carries three optional
timestamps, each arriving as a string over REST, and deserializing them one at a
time meant every new field surfaced as a type error at a distant consumer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These assert the shape of the update document rather than its effect, which is the
level the bug they exist to prevent lives at: `$addToSet` on a whole document
stops de-duplicating the moment an entry can be mutated, so the guard has to be in
the query. A regression would reintroduce duplicate member entries, which is
invisible until someone counts participants.

Also pins that declining does not clear `joined` — a member can dismiss the ring
and join later — and that `joinedAt` is omitted rather than stored as undefined.

`BaseRaw` is stubbed because it participates in a circular import that leaves it
uninitialized when this module is loaded directly by jest; only its constructor
matters here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call-history page could only ever record VoIP calls: `CallHistoryItem` was a
union of two `type: 'media-call'` variants, both 1:1-contact-shaped. A conference
is room-and-many-participants shaped, so it joins the union as a sibling variant,
`IVideoConferenceHistoryItem`, carrying the room, the conference title, and how
many members actually joined.

Ending a group conference writes one item per member, since history is per-user.
`buildConferenceCallHistoryItems` is kept pure and DB-free so the per-member
semantics are unit-testable: `direction` is outbound for the creator and inbound
for everyone else, and `state` is `ended` for a member who joined, `not-answered`
for one who did not. Only members get an item and a member either joined or was
rung and didn't, so not joining is not answering — whether they declined or just
ignored it. Reporting an ignored ring as a normal ended call would hide a missed
conference.

`setStatus(ENDED)` is public and reachable more than once for the same call, so
the write is guarded on the call not already having ended; without that, every
member collects a duplicate entry. A history failure is logged and swallowed
rather than failing the end of the call.

Every item type shares one collection, so the existing direction/state filters
needed no change; only the free-text search gained a branch matching the
conference title. The table dispatches conference items to a new row showing the
room and joined count instead of a contact.

Two type holes that widening the union opened are closed here too:
`MediaCallHistoryExternal` typed its payload as
`Exclude<CallHistoryItem, IInternalMediaCallHistoryItem>`, which silently began
including conference items and would have broken `item.duration`, and its
`isExternalCallHistoryItem` guard tested `type !== 'media-call'` — previously dead,
now wrong. Both are narrowed to the external media-call variant they mean.

Deep-linking to a conference item's detail route still falls through to the
generic "call info could not be loaded" panel; a conference detail view is out of
scope and noted in the docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Conference membership deliberately grants no room access, so a member can be in
the call while unable to read its chat. Rather than forcing that choice on whoever
adds a participant, it is surfaced once it actually matters.

`video-conference.info` reports `membersWithoutChatAccess`, and
`POST /v1/video-conference.share-chat` fixes it. Access is asked per member with
`canAccessRoomIdAsync` rather than derived from subscriptions, because reading a
room doesn't always require one — a public channel is readable by anyone unless it
belongs to a private team. Conferences are small enough that getting that case
wrong matters more than the extra reads.

The remedy asks the room whether it can take new members —
`allowMemberAction(room, RoomMemberActions.INVITE)` — instead of testing for a DM.
The room type owns that rule, and it covers cases a `t === 'd'` check misses, such
as a federated DM that can grow. A room that can take members is joined; one that
can't has its chat moved to a discussion carrying everyone.

`ChatAccessNotice` renders in the chat panel, where the remedy is in context, and
moves up to the conference page while the panel is closed — so it can't be missed,
and is never shown twice.

This also closes the divergence documented earlier:
`createConferenceDiscussionWithParticipants` now builds from `discussionRid || rid`
rather than always the original room, so a second discussion no longer silently
drops everyone added since the first. It is reached as a remedy now, which is why
the fix matters again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Captures what isn't visible from the code: that group ringing was dead code before
this (the server broadcast `ring` and no client handled it, so enabling it makes
conferences that were silently not ringing start to), that declining creates a
membership entry because there is nowhere else to record it, where the test
coverage lives and why those runners, and the gaps left open — no members panel, no
conference detail view in call history, one access check per member on info, and
the 10-user cap per add that guarantees an add always rings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dionisio-bot

dionisio-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Looks like this PR is not ready to merge, because of the following issues:

  • This PR is missing the 'stat: QA assured' label
  • This PR is missing the required milestone or project
  • This PR has an invalid title

Please fix the issues and try again

If you have any trouble, please check the PR guidelines

@changeset-bot

changeset-bot Bot commented Aug 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9e8f454

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 18 packages
Name Type
@rocket.chat/core-typings Minor
@rocket.chat/core-services Minor
@rocket.chat/model-typings Minor
@rocket.chat/models Minor
@rocket.chat/rest-typings Minor
@rocket.chat/ddp-client Minor
@rocket.chat/fuselage-ui-kit Major
@rocket.chat/ui-client Minor
@rocket.chat/ui-kit Minor
@rocket.chat/ui-voip Minor
@rocket.chat/i18n Minor
@rocket.chat/mock-providers Minor
@rocket.chat/meteor Minor
@rocket.chat/livechat Patch
@rocket.chat/ui-contexts Major
@rocket.chat/ui-composer Major
rocketchat-services Patch
@rocket.chat/web-ui-registration Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: eb049d1c-4f0a-4965-8bd7-86e5b582023d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 2, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.52842% with 276 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.28%. Comparing base (94478c6) to head (5b27cc9).
⚠️ Report is 47 commits behind head on develop.

Additional details and impacted files

Impacted file tree graph

@@             Coverage Diff             @@
##           develop   #41657      +/-   ##
===========================================
+ Coverage    68.73%   69.28%   +0.55%     
===========================================
  Files         4155     4212      +57     
  Lines       159672   163491    +3819     
  Branches     28010    28911     +901     
===========================================
+ Hits        109743   113281    +3538     
- Misses       44753    45045     +292     
+ Partials      5176     5165      -11     
Flag Coverage Δ
unit 71.14% <92.37%> (+0.44%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

rodrigok and others added 13 commits August 2, 2026 10:32
Ringing on add is the first case where a call rings someone with no access
to the room it belongs to, and the incoming popup was built entirely around
that room: it read it from the client store and returned null when it wasn't
there, while still calling focusManager.focusFirst() — which then looked for
the parent of a node the focus scope never got.

Incoming popups now render without a room, describing the call from the
conference record the popup already fetches. The popups that act on a room,
starting or placing a call, still require one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1:1 accept is a handshake: the callee publishes `accepted` and waits for the
caller's client to reply `confirmed`, giving up after 5s. A server-originated
ring has no caller waiting, so running that handshake left a user who had
just been added staring at "No response from remote user".

Incoming calls now carry a `handshake` flag. Without it, accepting joins the
conference outright — membership is what authorizes joining — and declining
records the decline without publishing `rejected` to whoever added them,
which their client would read as their own call being turned down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… chat

The notice for members who can't read the chat applied the only remedy the
room allowed, behind a "Give access" button, on a hand-rolled banner with
unreadable contrast.

Both ways out give something away, in different directions: inviting exposes
the room's whole history to an outsider, while moving the chat to a discussion
leaves the earlier history behind for everyone already there. So the notice now
opens a modal that names the members and spells out each consequence next to
its own button. Which action leads is a privacy judgement — private rooms and
DMs lead with the discussion, public rooms with the invite — and `share-chat`
takes a `mode`, re-deriving `canInvite` server-side rather than trusting it.

The banner is now the one rooms use for announcements, so it inherits readable
contrast. `AnnouncementBanner`'s className was too narrow to accept css output.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…age not found"

A conference member with no access to the call's room got the generic
not-found panel — "The page does not exist or you may not have access
permission" — for a situation that is neither an error nor theirs to fix.

The server already reports who can't read the chat, so the panel checks for
the current user and says what's going on, rather than letting the room fetch
fail and interpreting that. The notice offering to share the chat now hides
itself from those same members: they are who it would be shared with, and
share-chat can't work from their side anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Inviting the missing members leaves the conference record untouched — only
who can read the chat changed — so nothing told the participants to look
again and the notice stayed up until a reload. Only the discussion path
broadcast anything, because moving the chat is a change to the record.

Adds `chatAccessUpdated` alongside `discussionUpdated`: the two ways the chat
can change under a participant, both answered by reading the conference again.
The participant who acted also invalidates locally, rather than waiting on the
round trip to see their own notice go away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The modal reads the conference's room from the client store to exclude people
already in it. That room is only seeded for participants who can read the
chat, and the autocomplete was gated on having it — so it stayed permanently
empty for exactly the members the membership model exists to serve.

The exclusion list is best-effort now: no room means nobody is excluded, and
adding still works, since the endpoint only needs the call id and usernames.

Also stops the modal reporting success for a no-op. Users already associated
with the call are skipped server-side, and `add-participants` says which ones
it actually added — selecting only existing members rang nobody.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Being added rings you, but the ring only reaches a client that is on screen
and it fires once — someone on another tab, or with no client open, had no
signal at all beyond finding the call in their history afterwards. The
secondary "you can see the chat now" path had a desktop notification; the
primary one didn't.

It deliberately carries no room name, which is what stops the click from
navigating: membership grants no room access, so the room behind the call may
be one they can't open. Clicking focuses the app, where the ring is; the
"Join call" action joins the conference itself. The payload's room fields
become optional for that reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The stream authorized only against the conference's membership list, so a room
member who opened the conference before their join landed was refused — and a
refused subscription is never retried, leaving them deaf to the call's updates
for the rest of the session. It only worked because joining happens to precede
the window opening.

Now it accepts membership or access to the room the chat lives in, the same
pair `video-conference.info` accepts. Both halves are needed: members may have
no access to the room the call started in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which way of sharing the chat leads was written twice — as the modal's primary
button and as the server's default — and they had already drifted: the server
defaulted to inviting whenever the room allowed it, including for private rooms
the modal deliberately leads away from.

`chatAccessLeadsWithDiscussion` and `resolveChatAccessMode` are pure functions
both sides now use, so the rule is stated once and tested directly. The service
itself resists unit testing — proxyquiring it means stubbing some thirty
modules, one of which opens a Mongo driver at import time — which is the reason
to move the decisions out of it rather than to leave them untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pins the behaviour that had none, and that the last few fixes turned on:

- accepting a server ring joins outright while accepting a 1:1 call negotiates,
  and declining a ring records the decline without telling the adder their own
  call was rejected;
- the chat-access notice is shown to participants who can act on it and hidden
  from the members it is about;
- both conference stream events refetch the conference, and the chat panel
  follows it into a discussion.

Each was checked against the bug it guards: the stream test fails with either
subscription removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ntation

The "Adding Participants" section still described the keep-history choice and
its per-room-type behaviour, which the membership model removed — the most
misleading kind of stale, since it read as current. Rewritten around what
adding does now, with the old flow kept as history because the divergence it
had is worth remembering.

Also corrects the access-control section (every endpoint shares one check that
accepts membership, so add-participants no longer disagrees with join), the
endpoint and stream tables, and records what the audit turned up that is not
worth fixing yet under Improvement suggestions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Membership without room access is the feature's premise, so it is worth
recording that it was confirmed in a real database and not only in tests — and
worth being explicit that the decline path has not been observed there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Group conferences left no call-history entry in practice. The write hung off
`endCall`, which runs when something tells Rocket.Chat the call is over — an
app provider posting ENDED, or a direct call hanging up. Nothing in the Jitsi
app does that, so its conferences sit at STARTED until `videoConferencesCron`
expires them past their 24h TTL, and `expireCall` wrote no history at all.

Expiry isn't an edge case, it's how a conference normally finishes with a
provider that doesn't report the end back — so both paths write now. The
repeat guard moves into `shouldWriteConferenceHistory`, which both share:
each path can fire more than once for one call, so a call that already carries
`endedAt` is refused.

Conferences expired before this cannot be backfilled — they already carry
`endedAt`, indistinguishable from ones already written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rodrigok and others added 28 commits August 2, 2026 23:04
The membership model has been invisible until now: a decline was recorded, a
member added from outside the room was counted in aggregate, and neither could
be seen against a name. A members panel opens in the side panel — by default,
because on arriving in a call the useful question is who else is here, and for
the caller of a call still ringing it is the only place that answers it.

The panel and the chat share that space, so only one is open at a time and a
new bar button switches between them; the provider bridge's chat commands act
on the chat and leave another panel alone rather than closing whatever happens
to be open. Adding people moves here, next to the list of who is already here.

Each member carries one status. Beyond invited/joined/declined there is
**left** — we track `leftAt` now, and without it someone who joined and left
would read as still on the call. The entry accumulates rather than replaces, so
the fields are read in order of what happened last: being in the call beats
everything, having left beats an earlier decline since they did answer. Anyone
not currently in the call can be rung back, one at a time, so
`video-conference.ring` takes an optional `users`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Leaving is reported on `pagehide`, which fires on a reload exactly as on a
close — indistinguishable from it — so refreshing the call window ended the
call when nobody else was in it. The emptiness is now confirmed after a grace
period instead, which coming back cancels by simply being in the call again.
That also absorbs a network blip taking the window down for a moment.

Joining also invalidates the conference read: the broadcast announcing it can
beat the stream subscription being established, which left the members list
showing the user themselves as absent until something else moved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… it can be

`getChatAccess` asked `canAccessRoomIdAsync` once per member, on every read of
the conference — which is now also refetched whenever the membership or the chat
changes, so the cost is paid often.

A plain public channel and a plain private room are answered from a single
`Subscriptions` query for all the member ids: the public one is free for
everyone not explicitly banned, the private one needs a real subscription.

The per-member path stays for the cases where access does not reduce to a
subscription, and those are not exotic: a public channel in a team grants access
through team membership, and only `teamDefault` channels auto-subscribe, so a
team member routinely has no subscription while still being able to read. A
discussion delegates to its parent room's rules, recursively, and an ABAC room
can bypass subscriptions entirely. Reimplementing any of that here would be a
second, drifting copy of the authorization rules.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`addMembers` and `declineCall` were exercised only through the UI, and the
endpoints only by hand — the latter being where the interesting cases live:
authorization by membership, the real Mongo writes, the room-mutating paths.

Unit specs pin what the two methods must not do as much as what they do: an add
must skip a user who already has an entry rather than overwrite their state, and
must ring nobody when it added nobody; a decline must never end the conference,
which is what separates it from rejecting a 1:1 call. The desktop notification
is asserted silent and room-less, so a call can't announce itself with the
new-message sound or offer to open a room the member may not be able to see.

The REST suite follows the provider-app harness and is EE-gated. It has not run
locally — the API suite authenticates as a fixture admin a real development
workspace doesn't have — so it is type-checked only and its first CI run is the
real one. Said so in the docs rather than implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The document had grown a completed roadmap, suggestions that had since been
implemented, and gaps that had since been closed — all of which read as if they
were still true.

The phase-by-phase plan is gone; it is git history, and what mattered about it
was the reasoning, which stays. "Known gaps" becomes "deliberate limits", since
what was left in it were choices rather than oversights. The suggestions list
now holds only what is genuinely unfinished, and the members panel, the panel
switching and the newly cheap chat-access reads are described where the rest of
the behaviour is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…access banner

Six changes to the call chrome.

The members panel now reads like the room's own members list — avatar, name,
`@username`, presence — and splits into **in the call** and **not in it**,
because the halves answer different questions. A section nobody is in isn't
shown, and members in the call need no label beyond the section they are in. No
search box: fine at conference scale, and noted as the shape to copy if that
changes.

A member's ring button appears only when there is something to ask for: not
while they are in the call, and not while their phone is already ringing.
Knowing which required recording the ring — `ringingAt` on the entry, written by
every ring including the one that starts a direct call — so it is knowable to
everyone rather than only to whoever pressed the button. A ring stops on its own
with nothing to announce it, so each row wakes itself when its window is up.

The chat-access banner counts only members who have **joined**. Someone merely
invited may never turn up, and a banner about a person who isn't there asks
everyone else to fix a situation that hasn't happened. It also moves above the
call and both panels: it is about the call rather than about whichever panel is
open, and one that moved as panels changed would read as a different message
each time.

The bar gains two counts — how many people are in the call, and what is unread
while the chat is closed. The unread one goes through the sidebar's own
`useUnreadDisplay`, so a mention reads as urgent in both places and a muted room
stays quiet in both; the members count is `secondary`, because a count of who is
here is information and a red badge would read as a problem.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… an icon

The unread badge never appeared. Both the subscription seeding and the
`subscriptions-changed` watcher lived inside the chat panel, and that panel
stopped being mounted when it stopped being the only one — so with the members
list open by default, the badge had no data and nothing to refresh it. Keeping
that subscription is the page's business now: the badge exists precisely when
the panel is closed, and nothing else on the page would do it, since the
conference renders outside the main app and the sidebar's watcher never starts.

The "no chat access" tag becomes an icon beside the member's name. It qualifies
who that person is in the call, and a second line pushed every row apart for
something most members never carry.

Verified live: a member added from outside the room shows exactly one icon and
no banner — he hasn't joined — and a room with unread renders both badges, the
count of people in the call and the unread count, with the tab title agreeing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`AuthenticationCheck` decided from `useUser()` alone, which is null while a
stored session is still being resumed — so it read "no user yet" as "not logged
in" and rendered the login form. In the main app that window is invisible,
because the session is already in memory. A call popout is a fresh window: it
showed a login form for a few hundred milliseconds to someone who never asked
for one, then replaced it with the conference.

It now waits on either signal that a resume is under way: `isLoggingIn` once
Meteor has started one, or a stored login token for the instant before that,
where nothing is in flight yet but a session plainly exists. The placeholder is
the one the route already supplies — the conference passes its own, so it still
never flashes an app skeleton — and defaults to what the chain showed before.

The two ways out stay reachable: a stale token is cleared when the resume fails,
landing as an ordinary logged-out visitor, and a forced login goes straight to
the form regardless of what is in storage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two requested features recorded before either starts: ongoing calls listed in
the sidebar with join and decline, and getting back a call you declined from the
call history.

Records why call history can't source the "calls I can join" list as it stands —
a history row is written when a conference *stops*, so an ongoing call has none —
and that the concern behind the suggestion, not inventing another persisted
concept, is better served by asking the conference collection, which already
stores membership and liveness. That leaves the history side as a presentation
question rather than a data one.

The four open questions are now settled, and two of the answers do real work:
hiding calls nobody is in is what keeps a channel-wide audience bearable and
what makes a call abandoned for another disappear on its own; and declining
hides the call from the sidebar while the history keeps it, which is what makes
the history the route back rather than a second copy of the same list.

The sharpest consequence is that "one call at a time" is not the same as the
shared window replacing a page: the abandoned call has to be left explicitly, or
its participant stays in it and it never empties.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ringing was the only route into a call someone didn't start, and a poor one: it
is one-shot, it lasts seconds, and a conference started in a room with more than
ten subscribers rings nobody at all. So there were calls a user was entitled to
join with no way to reach them, and a call they declined was gone for good.

A sidebar section now lists the calls running that this user may join, with join
and decline; the call-history page lists the same calls above the past ones, and
unlike the sidebar it keeps the declined ones — which is what makes it the way
back to a call that was turned down.

Nothing new is stored to support it. `listJoinableCalls` asks the conference
records, which already hold membership, liveness and the room, and scans running
conferences rather than the user's rooms, so its cost follows how many calls are
in progress. A sparse index on `endedAt` keeps that to the live handful. Calls
nobody is in are left out, or a conference nobody ended would be advertised for a
day.

Three choices worth naming. The audience is room *membership*, not room access:
a public channel is readable by anyone, and a call in a channel the user never
joined has no business in their sidebar. A direct call is named from the reader's
own subscription, since a DM has no name of its own and is named after the other
person. And joining another call posts the leave **explicitly** — the shared
window already replaces the first call's page, but replacing a page is not
leaving a call, and without the leave the abandoned call keeps counting its
participant and never empties.

Liveness is polled every 20s rather than pushed. Announcing a call to everyone
who could join it means a broadcast to every subscriber of its room, which is the
same fan-out that makes ringing a large room impossible — the problem this
works around. Documented as a trade, with the cheaper push noted as follow-up.

Verified live end to end: the sidebar listed running calls by name with the
right actions, declining removed it there, and it then appeared in the call
history marked declined with a way back in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The sidebar listed the call the reader was already in, which read as "in
call" and offered nothing to do about it. Those rows are gone: the list is
only calls to join or turn down, newest first, three at a time with a
toggle for the rest.

Joining now also reconciles presence server-side. A window that dies
without reporting its departure left its user counted as present forever —
five running calls each thought they still had them — so `addUserToCall`
leaves every other call the joiner is still counted as being in.

Also renders the leave-and-join confirmation through `Trans`, so the name
of the call being left is emphasised rather than showing its markup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two reports, both about a call window that never got to speak for itself.

The join is posted by the main app, before the window opens, so accepting a
call and closing the window while it was still loading left the user listed
as present in a call they never saw — holding it open. The opener now
watches the window it opened and reports the leave when it goes.

And "Start a call" stayed on screen behind the call it had started: a group
call closed it via `calling/ended`, but a direct one keeps ringing and never
emits that. It now closes itself once the call has its own window.

`MockedAppRootBuilder` grows inert video-conf capability/preference reads —
every popup does them just by rendering, so throwing failed such a test on
the render rather than on anything it meant to assert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call window opens the moment someone asks for it — it has to, or the
browser is entitled to refuse it — but being in the call is a separate
decision. It now shows a preflight first: a self-view, what the call is
called, the devices you will arrive with, and a name field for whoever
started a group call.

That is what makes the join honest. `joinCall` posts nothing under
persistent chat; the conference page joins from the preflight, so mic and
camera reach the provider's URL and presence is recorded when the user
actually enters. The room's popups stop asking about devices, since the
question is now asked where the user can see themselves.

Naming a running conference is new (`video-conference.rename`, the creator
of a group call only). It is what the provider is told to call the meeting,
read at join time, and what the sidebar and history list it as.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Creating a direct call was asking the other side to answer it, while the
caller was still on the preflight choosing a camera — answering into an
empty room. Now `startDirect` rings nobody and sends no push; the ring goes
out when the caller actually arrives in the call, to members who have never
been rung, so a rejoin rings nobody and a second attempt stays the call
window's own "ring again".

The preflight says so before it happens — "Alice will be notified when you
start the call", and the button is the call rather than a join.

With persistent chat off there is no preflight to wait for, so the caller's
client keeps doing the 1:1 handshake ring from the room, unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lick

Clicking call posted a message in the room, rang people and wrote a call
into everyone's history — for a call the user might still walk away from.
Now the click only opens the window, at /conference/new?rid=…, and
ConferenceStartPage runs the preflight against the room: the name comes from
the reader's own subscription, the devices from the capabilities endpoint.
Confirming is what starts the call, and cancelling leaves no trace.

Two bugs found while testing this live:

The context still pointed `startCall` at VideoConfManager — the callback
that opens the window instead sat unused, so nothing changed for the user.
Covered by VideoConfProvider.spec now.

And `useConfinedNavigation` treated the move from /conference/new to
/conference/:callId as an escape attempt, shipping the started call to a new
tab and leaving the preflight spinning. It now allows a conference and
nothing else.

Naming is editable again on the way in: the field carries its value to
`start` for a new call, or to `rename` for one that already exists. Cancel
sits beside the confirm button and closes the window.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A self-view promises a choice the preflight cannot make: all a provider can
be told is whether to start with camera and microphone on, never which
devices to use — so the preview could show a camera the call never ends up
using. The tile now states what will happen ("your camera is turned off" /
"your camera will be on") and says device selection happens inside the call.

Drops `getUserMedia` entirely with it: no permission prompt, and no camera
held open moments before the provider asks for the same one. The tile is
where a real preview goes once a native provider can take a device per
stream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The history page had two lists of calls: a section of ongoing ones above
the table of past ones, each with its own idea of what a row looks like. A
call in progress is a call that happened — it just hasn't finished — so it
now belongs to the same list, told apart by an `ongoing` status and by
having a Join button where the others have their menu.

That needs the history written when a call starts rather than when it ends.
`recordConferenceInHistory` upserts an item per member as the conference is
created, again whenever membership moves, and once more at the end, where
each member's own outcome (`ended` / `not-answered`) is finally known.
`{ uid, callId }` is already unique, so the write is an upsert and repeating
it is harmless — which is what let the old `endedAt` duplicate guard go.

Two things fall out: someone who was rung and never answered has a row
while the call is still running, so turning a call down is not the end of
it; and the client reads one source again, with `ongoing` a state like any
other in its filters.

`ongoing` joins the shared `CallHistoryItemState`, so the VoIP renderers
that switch over every state render it too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An incoming call took over the screen with a popup that had to be dealt
with before anything else could happen. It is now the first item of the
list the other calls are already in — under its own heading, bigger than
the rest, with accept then decline below it in the same order as the join
and dismiss underneath. The ring still sounds, and silencing it stops that
sound without answering: the call stays where it is until the user decides.

Two knock-on changes make the list the one place calls live:

The room's call button opens the call window directly. It used to open a
popup to confirm and pick devices, which the preflight now does with the
user able to see what they are joining.

`navbar/NavBarItemOngoingCalls` stands in for the sidebar's dock when there
is no sidebar on screen: the same list in a dropdown, blue for calls that
are running and red while something is ringing, opening itself on a ring —
a ringing call the user has to go looking for is a missed call.

Each item now shows who is in the call rather than how many: three avatars
and a `+N`, from participants the joinable list carries. The list scrolls
instead of growing without end, and the show-all toggle names its total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The screen serves four situations and looked the same in all of them. It
now leads with a title — "Start a new conference" / "Start conference with
Alice" when nothing exists yet, "Join the conference" / "Join conference
with Alice" when it does — which took replacing the single `confirm` prop
with an `action` and an `isDirect`, since a call *with someone* reads
differently from one *in a room*.

The rest follows from having a title: the room's name is no longer repeated
under the camera tile, and the name field lost its caption, since the field
is its own label. It moves above the tile — it is the one thing here about
the call rather than about how the user arrives in it — and a conference
that doesn't exist yet is offered as "Meeting in <room>" rather than the
room's bare name.

The mic and camera toggles move inside the tile, bottom-centre, where a
preview's own controls belong; the preflight's copy of `CallBar` existed
only to hold them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing here changes what the feature does. It removes code that no
longer earns its keep after several rounds of feedback added a mechanism
each: one event where there were three, one prologue where there were
six, one notification builder where there were two.

- Three stream events (`discussionUpdated`, `chatAccessUpdated`,
  `membersUpdated`) become one `video-conference.updated`. The only
  subscriber registered the same callback for all three and never read
  the one payload, so the answer was always "read the conference again".
  That also collapses `notifyVideoConference`'s type gymnastics, which
  existed to keep three argument tuples apart.
- The six conference endpoints shared a verbatim load-and-authorize
  prologue; `loadAccessibleConference` now owns it, and returning the
  caller's id with the call retires the dead `|| !userId` narrowing.
- The two desktop-notification builders differed in four values, so they
  take four values. Whether a **room** travels is the real distinction:
  that is what lets the click navigate, and someone merely added to a
  call can't be sent to a room they may not read.
- `VideoConfDeclineProps` and `VideoConfLeaveProps` were
  character-for-character copies of `VideoConfCancelProps`; one
  `VideoConfCallIdProps` serves all three, keeping the published name as
  an alias.
- `JoinableVideoConference.rid`/`.type` had no reader: a list needs
  enough to decide whether to walk in, and joining goes by `callId`.
- `VIDEO_CONF_RINGING_LIMIT` existed twice with comments requiring the
  two to be equal, and "has this member been asked yet" was spelled out
  on both sides of the ring. Both now live in one place.
- `addMemberById` stops taking `joined`/`joinedAt`: being a member is not
  being in the call, and `setUserJoinedById` ran unconditionally next to
  every caller that passed them. `addAbsentMember` names the two paths
  that need a member who isn't present.
- `leaveOtherCalls` asks Mongo for "still in it" instead of reading every
  membership and sifting in JS; `shareChatWithMembers` no longer re-reads
  the conference for usernames `getChatAccess` already had in hand.

Two deliberate behaviour changes, both small: the invited notification
had `audioNotificationValue: ''`, which played the *new message* sound
for a call — the bug already fixed in its sibling — and its payload `_id`
is now the call rather than the room, matching the other path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call window grew its own versions of things the app had already
solved. This puts them back on the shared ones and deletes the copies.

- `AddParticipantsModal` hand-rolled an autocomplete, an options list,
  selected rows and remove buttons. `UserAutoCompleteMultiple` — what the
  room's own "add users" flow uses — does all of it; it only lacked a way
  to leave people out, so it takes `exceptions` now. Picking people in a
  call looks like picking people anywhere else, and the modal is a third
  of its former size.
- `ConferenceRoomPreload` fetched the room, stored it, and marked the
  cached stores ready — but `useOpenRoomById` fetches that same room a
  moment later, so the panel issued two `rooms.info` for one room. Only
  the ready-marking was ever unique, which is all `ConferenceStoresReady`
  does now. (The room UI's own not-subscribed rule now applies to the
  chat panel instead of being bypassed by the seeding; every ordinary
  user has `preview-c-room`, so this shows up only where it was revoked.)
- The chat and members panels had byte-identical headers → `CallPanelHeader`.
- The two call rows had the same head — icon, name, who's in it → `CallSummary`.
- "Is this ringing still ringing?" was two copies of a throwaway state
  plus a `setTimeout` → `useRingingExpiry`.
- "Can this user read the chat?" was computed three ways from two prop
  shapes → `hasConferenceChatAccess`, and the members panel takes the
  whole `chatAccess` like its sibling instead of a bare id array.
- The containers around the calls list mounted the *full* hook — decline
  mutation, silence state and all — to read two lengths. Split into
  `useOngoingCallsList` for them and `useOngoingCalls` for whoever
  renders it.
- `useMessageBlockContextValue` hand-rolled a route subscription;
  `useCurrentRouteName` now joins the family of one-line router hooks
  next to `useCurrentRoutePath` and `useLocationHash`.
- `CallBarActions` folded into `CallBar`: one call site, always at the
  inline end, and its centred variant existed only in a story. The native
  conference can claim the centre when it has controls to put there.
- Dropped: three barrel files for two importers, a 466-line snapshot of a
  static bar (replaced by asserting its actions are reachable), an unused
  `videoCallWindow.close` declaration, two returned-but-never-read hook
  values, two orphan i18n keys, and a `useCallback` with one caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The service specs were written one at a time, and the shared harness
arrived partway through. Each still assembled its own copy of the same
arrangement, so the suite grew a maintenance cost proportional to the
number of files rather than to what they test.

`testHarness.ts` now owns `createService` (the ~25 inert module stubs, the
do-nothing core-services trio, and a models map defaulted to inert),
`cloneFixture`, `resetAll` and `ringedUserIds`. `leaveCall.spec.ts` —
which never adopted the harness at all and re-listed every stub plus all
four fixture builders verbatim — goes from 413 lines to 285.

`ringMembers`, `addMembers` and `ringOnArrival` become one `ringing.spec.ts`:
they all answer "who gets rung", all stub `setUsersRingingById`, all read
the answer out of the same broadcast, and each separately loaded the
3000-line service.

Specs that restated a lower layer are trimmed to what only they can show:
`conferenceHistory` keeps *when* history is written and drops the
member-state mapping that `callHistory.spec.ts` pins on the pure
function; `membership.spec.ts` folds into `memberStatus.spec.ts`, where
the legacy-flag rule is now stated once instead of four times; the
ringing cap is asserted on `shouldRingVideoConference` plus one
service-level wiring case, not three.

Boilerplate becomes tables: 13 near-identical e2e validation cases
("unknown callId" ×7, "unauthenticated" ×6) and seven "does not
intercept" cases in `useConfinedNavigation.spec.ts`. Client fixtures
duplicated two and three times move into `views/conference/testFixtures.ts`.

Dropped four cases that asserted nothing usable: a `marginInlineStart >= 0`
check that is true for every possible value, a second copy of the lead-action
rule asserted through a Fuselage class name, a repeated post-`startCall`
`isCalling()`, and three ajv-testing-ajv cases.

Bite-checked: with the ringing cap removed from `shouldRingVideoConference`,
the merged spec fails. Behaviours covered are unchanged — every deleted
assertion has a named owner a layer down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…epeating

The doc grew alongside the feature and in places describes decisions the
branch itself reversed. Five things it said were wrong:

- Call-history items are **not** written once when a group conference
  stops. They are written from the conference's creation onward, and
  direct calls are logged too — the accurate account was already further
  down the page, so the stale section now defers to it.
  `shouldWriteConferenceHistory`, which it explained at length, no longer
  exists; the `{uid, callId}` unique index and `upsertMany` are what make
  repeat writes harmless.
- The preflight was described as showing "a self-view", two paragraphs
  above the section explaining why it deliberately shows no such thing.
- Leaving said a call ends when the last person leaves, never mentioning
  the 10s grace period that makes a reload survivable.
- `Key Files` pointed at two files that don't exist.
- The test-coverage table listed five specs two or three times, pointed at
  a spec that had moved, and closed by asserting `VideoConfService` "is
  not unit-tested" directly above the table listing its nine specs.

Also brought in line with this pass's code: one `video-conference.updated`
stream event where there were three, one `VIDEO_CONF_RINGING_LIMIT`,
`ConferenceStoresReady` in place of the room-fetching preload, the shared
call-bar and panel headers, and the picker the add-participants modal now
uses.

Reversed decisions retired: decision 5 kept "the current floating overlay
for every case", and the future-work item was to dock it — both now
describe the opposite of what shipped. Sections that said the same thing
two or three times are merged: the ringing cap was explained five times,
`Deliberate limits` restated two decisions and a paragraph, the
chat-access lead rule was stated twice, and one paragraph appeared
verbatim twice. Three scattered "Verified …" blockquotes become one
section, which now also records the decline that had been listed as never
observed live.

Restructured so behaviour comes first: "Reaching a call without a ring"
was buried after the rationale sections, and its own subsections mixed the
list, the navbar stand-in and the server endpoint together.

993 → 898 lines. Every internal anchor still resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four entries described one feature at `minor` over an overlapping
package set — and three described behaviour later commits on this branch
reversed, which would have shipped a release note contradicting itself:

- the participants entry advertised dial-out numbers (removed rather than
  left as dead UI) and the keep-history room-vs-discussion choice, which
  the membership entry then said the endpoint no longer takes;
- the call-history entry said **group** conferences, written when a
  conference **ends** — since changed to direct calls too, written from
  the moment a call starts;
- the chat-access entry said the server picks the sharing mechanism, which
  became the user's choice with the server only refusing an invite it
  can't honour.

None of the four mentioned the call window, the preflight, the ongoing
calls list, the outcome modal, or the `ring`/`rename`/`leave` endpoints —
all of it user-visible. One entry now covers the union of the packages,
written from the code as it stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Matrix comparison

The doc had grown past the point where prose alone conveys the flows, so
it becomes a folder — `README.md`, following `docs/proposals/apps-converters-zod`
— with four diagrams covering a call's life: starting one, being called,
adding people and what they can read, and how a call ends.

The diagrams carry their own styling as presentation attributes rather
than CSS classes, with light fills and dark text, so they render in
either GitHub theme and in an editor preview.

Alongside them, an analysis of how this implementation compares to
MatrixRTC on the two questions it also has to answer — who is in a call,
and who may join it. The short version: Matrix has no call object at all
(a session is the temporal overlap of `m.rtc.member` events), and almost
every difference follows from that. Three things there are worth
borrowing and are noted as such:

- a presence lease, replacing our four liveness-recovery mechanisms with
  one that also covers the case none of them cover — a client that stops
  responding without ever reporting a departure;
- an explicit expiry carried on the ring, instead of the three constants
  clients must currently agree on;
- enforcement at the provider, so a leaked conference URL stops being a
  capability.

The MSCs it cites are all still open and MatrixRTC has changed shape more
than once, which the document says rather than presenting them as settled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The branch carried several things that were separable from the feature it
exists to deliver, and one that nothing used at all. Taking them out now
means the first PR argues for one idea rather than six.

Removed outright:

- the provider -> parent postMessage bridge. Nothing in the repo speaks
  `rocketchat:conference`, including the bundled Jitsi app; it was an API
  built for a consumer that does not exist.
- participant faces in the ongoing-call list, back to the count they
  already fell back to. This also drops `participants` from the joinable
  payload, so the API contract carries one fewer public field.
- two Storybook stories for components with no states worth browsing.

Deferred to follow-ups, each a complete improvement the feature works
without: the call-outcome modal, the room call-list regrouping, and the
navbar stand-in for a collapsed sidebar. Confined navigation keeps the
interception -- without it a link in the chat panel tears down the call --
but the three delivery strategies collapse to one: a `noopener` tab. The
desktop bridge that went with them leaves `desktop-api` untouched by this
branch entirely.

The membership e2e suite moves to its own PR. It has never run locally --
it authenticates as a fixture admin a dev workspace does not have -- so
carrying it here would let an unvalidated suite redden a PR for reasons
unrelated to the code under review.

Folding the small single-caller files into their callers was only partly
possible: `react/no-multi-comp` forbids two components in one file, which
is three of the five. Those stay as they are; `useConferenceCallUrl`
became a helper in its one consumer.

Deferrals are recorded in the feature doc with the SHA to restore from,
and the changeset now claims only what ships.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Several pieces of the call window were hand-rolled against `Box` when the
design system already ships the same thing:

- `CallPanel` is now `Contextualbar`. Fuselage's is a `Box` carrying exactly
  the props this file set by hand -- flex column, no shrink, the inline-start
  border, full height, `width`/`position` as props. What is left here is the
  part that is genuinely new: the open/close width animation, and floating
  over the call on viewports too narrow to split.
- `CallPanelHeader` is the contextual bar's own header, title, actions and
  close, so these panels agree with every other closable surface about where
  a title sits and where dismissal is -- and inherit its focus ring, its
  `aria-label` and its `data-qa` hook rather than reimplementing them.
- `AddParticipantsModal` is a `GenericModal`. It was always that shape:
  a title, one field, confirm and cancel.
- `ConferenceMemberRow` uses the `Option` primitives that `CallMemberItem`
  two files over already uses.
- The preflight's mic and camera toggles are `VideoConfController`, which is
  what the room's start-call popup drives the same two toggles with. A user
  who sets them in one place and later meets the other now reads the same
  control, not a second one that happens to look similar.

`ConferencePageError` and `ConferenceUnauthorizedPage` were the same page
with different words, so they became one `ConferenceStatePage` with props.

One deliberate exception: the panel keeps `surface-light` rather than taking
the contextual bar's `surface-room` default, so this changes no colours.

Not changed: `ChatAccessModal` offers two different confirmations with their
own consequences, and `GenericModal`'s footer is one confirm plus a cancel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five things in the diff were not the feature.

The room kebab's Calls item had been renamed to *Conference call history*
with a history icon. That rename belonged to the call-list regrouping, which
was deferred two commits ago -- so what shipped was a rename with nothing
behind it, changing what every existing workspace sees for no reason. It
goes back, and takes its i18n key with it.

Two drive-by fixes ride along that are worth having and worth reviewing on
their own terms: a `defaut` -> `default` typo in the room members divider,
and hoisting `useVideoConfList`'s `select` so react-query can memoize it.
Neither has anything to do with conferences; both are a one-line PR that
merges in a day rather than a line buried in a hundred and fifty files.

Disabling join/call-back on message blocks inside the call window reached
across `ui-contexts` (a whole new `useCurrentRouteName` hook) and
`fuselage-ui-kit` to prevent something that isn't broken -- clicking join in
there now opens a second call window, which is odd rather than harmful. It
is deferred, and `@rocket.chat/ui-contexts` leaves the changeset with it.
`VideoConferenceBlock` keeps the part that is the feature: `users` is the
membership list, so it has to be filtered to those who actually joined
before any of it is counted or shown.

The *Ongoing* call-history filter takes an existing icon colour rather than
teaching `MultiSelectCustom` and its list a new one, which is two files in a
shared package for a single green phone.

Two comments named consumers that no longer exist -- the navbar dropdown and
the call-history page -- and now describe the code that is there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`addMemberById` writes `joined: false` on purpose: this branch made `users[]`
a membership list, so being a member and being in the call became two
different things, and arriving is `setUserJoinedById`'s job. The method's
signature doesn't even accept `joined`.

This test asked for the behaviour that replaced -- that passing `joined: true`
would be stored as-is -- and contradicted the test directly above it, which
asserts a new member defaults to not joined. Nothing passes those fields:
the join path calls `addMemberById` and then `setUserJoinedById`, which has
its own tests for marking someone present.

It was failing CI on this branch and on everything stacked above it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant