Skip to content

Show unread from other communities in the community switcher - #3459

Open
wpfleger96 wants to merge 10 commits into
mainfrom
duncan/cross-community-unreads
Open

wpfleger96 wants to merge 10 commits into
mainfrom
duncan/cross-community-unreads

Conversation

@wpfleger96

@wpfleger96 wpfleger96 commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

Why

Someone in several communities can't see that another community has unread without opening the switcher and checking each row. The old community rail badged other communities; the switcher that replaced it in #1273 does not. See #2922.

What

  • Each community row in the switcher, other than the selected one, ends with a badge. A number counts items that need you: mentions, DMs and replies in threads you follow (the sidebar's attention count). A dot means other unread. Muted channels and conversations your notification settings exclude don't count. A community whose unread isn't known yet shows nothing, never "all read".
  • The closed switcher button shows a small dot while any other community has unread. Its accessible name says so.
  • At startup, every joined community connects in the background and starts its unread check once, so a community you haven't opened since launch is badged too. This is the second commit. The seventh caps it at 8 communities (24 after the ninth); the rest are badged once visited. The eighth makes those background sessions give up their connection when the selected community or an agent's community needs it.
  • A community's badge disappears while its live connection is down or its unread is stale, and comes back on its own once it reconnects. It is also hidden while none of its channels have a known count. These are the third and fourth commits.

Commits

  1. feat(communities): badge other communities' unread in the switcher: the shared rule, the projection, the row badges and the trigger dot.
  2. feat(communities): connect every joined community for switcher unread: startup coverage.
  3. fix(communities): hide a community's switcher badge while its unread is stale or unknown: the availability rule for the switcher only. It applies on top of either commit.
  4. fix(communities): hide a community's switcher badge while its live connection is down: the switcher also follows the session's live status (session.live), because HTTP reads can keep evidence fresh while the WebSocket is down.
  5. fix(communities): paint the switcher row's unread dot: the row's ordinary-unread dot rendered 0×0; it is now inline-block, vertically centered.
  6. test(browser): expect every joined community to connect at startup: browser journeys written for a single connected community, updated to keep what each one proves.
  7. fix(communities): cap background unread sessions under the native socket limit: the projection opens at most 8 communities in the background, plus every community selected this launch. Each session holds one native relay socket, and the host refuses a 17th (src-tauri/src/federated_identity/socket.rs), so this leaves room for the selected community and agents' communities. Communities past the cap stay unbadged until you visit them. It also scopes the browser fixture's quota refusals to primary.
  8. fix(communities): yield badge-only sessions' sockets to selected and agent work: the 8 is only a startup limit; visited and agent sessions stay retained, so with 17 communities selecting the last one could fail with "Native sockets are busy". communities.open(id, true) now marks a session as held only for its badge. A badge-only open never takes the last socket (SESSION_LIMIT = 16, mirroring socket.rs). When a session opened for its own work (select, agent open, join) needs a socket and all 16 are held, the oldest badge-only session is disposed first, without purging device state, and the new session connects only after that disposal finishes. The closed community's badge goes blank and stays closed until it is opened for its own work. It also makes the WebKit sidebar-unread overlap test hold the app's 10s read deadline itself (same init-script pattern this file already uses), so a slow runner can't cancel the held unread batch before the test releases it.
  9. fix(communities): raise session limits and bound broker badge sessions: Will's limits: the Rust socket limit (socket.rs) and SESSION_LIMIT go to 32, and BACKGROUND_UNREAD_LIMIT goes to 24. On the browser development broker, every session holds a live stream on one same-origin HTTP/1.1 connection, and browsers allow about 6 per origin. So the broker path uses BROKER_SESSION_LIMIT = 2, which is enough for the selected community plus one badge. Badge-only sessions never go past it, and selecting another community closes the badge session first. The icon-congestion journey goes back to real live streams for its saved communities (dropping commit 6's live: !saved) and now runs with 6 and 8 saved communities, so a same-origin foreground request must still reach the server while icons are held. docs/communities.md now describes the 24 limit, visits, closing to make room, and the smaller broker path.
  10. fix(communities): hold the replacement's slot before announcing an eviction: eviction used to notify listeners before storing the session that needed the socket, so the unread projection could reopen a badge into the freed slot, or an agent watcher could build a second session for the same community. The replacement is now stored first, then listeners are notified, still closing the old session before connecting the new one. Badge-only sessions now leave the last slot free (>= limit - 1), so the slot work gets without eviction is never one a closed badge is still releasing. BROKER_SESSION_LIMIT is 3: selected + one badge + the reserve. The sidebar-unread overlap test now suspends only 10s deadlines started while its read is held.

How

  • features/notifications/community-unread.ts: observeCommunityUnread is the Dock dot's per-community rule moved out of bindUnreadIndicator, with the attention count added. It takes any community's session. createCommunityUnreads projects it for every joined community, keyed by id.
  • The Dock dot (indicator-unread.ts) now uses observeCommunityUnread for the selected community. Its behavior is unchanged, and its existing tests pass as they are.
  • The switcher reads the projection: CommunityPicker for the trigger dot, CommunityPickerItem for the row badge (shared CountBadge, or a dot).
  • Availability, third commit: observeCommunityUnread takes a known flag, and only the projection sets it. With the flag on, the observer publishes undefined while any counted channel's unread freshness is stale, which session.unread.stale() sets when the live connection drops. Recovery reads set it back to observed. Channels whose coverage isn't observed are left out of the count. The community counts as unknown only when none are observed. The fourth commit adds one subscription to session.live, and the observer publishes undefined unless its status is connected. That subscription is torn down with the others. Neither commit adds a timer or poll. The Dock dot leaves the flag off, so its behavior and its tests stay as they are.
  • Startup coverage, second commit: the projection opens each community with communities.open(id), which already serves agent communities, and calls unread.ensure() once per session. No relay change.

Foundation guidance

This touches three FOUNDATION files, for wiring only. Will approved the plan (unread badges on other communities, including connecting every joined community at startup):

  • src/app/services.ts: creates the projection and shares the notification gates with the Dock dot.
  • src/app/App.tsx: passes the projection to the shell (one prop).
  • src/features/communities/service.ts: the first commit adds a read-only retained(id) lookup. The second commit removes it in favor of open(id). The eighth adds an optional badge flag to open, a SESSION_LIMIT constant, and badge-only eviction in acquire; leave now calls the extracted non-purging dispose and purges separately as before.

Behavior change in the second commit

The app no longer connects only the selected community. It connects every joined community, including while Personal space is selected. Two services.test.ts assertions encoded the old behavior, and this commit updates them.

Testing

  • New community-unread.test.ts covers: count vs. dot, row quieting after a visit, manual unread, mutes and notification gates, unknown until mutes are confirmed, disposal, and a keyed projection that opens and repairs each session once and drops a community after leaving.
  • The third commit adds: unread → connection drops (stale) → absent → reconnect → badge back; every count unknown → absent; one unknown among known channels → still badged. The fourth commit adds: reads observed while the socket never connects → absent until connected; a drop and then a successful refresh while still down → absent until reconnected; the live listener detached on dispose.
  • New CommunityPicker.test.tsx case covers: the selected community is never badged, count vs. dot in row labels, the trigger dot and its label, and no badge when everything is read or nothing is known.
  • Commit 10 adds cases on the same limit-enforcing native connector: a badge can't take the last socket while other work still can; badge refill on the eviction notice; agent re-entry on the eviction notice (one connect, same session); the original full-limit sequence. Reverting the ordering or the reserve fails four of them.
  • Commit 9 rewrites that case on the native path, against a modeled host registry that refuses past SESSION_LIMIT and frees a socket when its scope aborts. It asserts no connection is ever refused, the 33rd connects, and the closed badge session frees its socket. It adds a broker-path case for BROKER_SESSION_LIMIT.
  • Commit 8 adds a service.test.ts case against retained sessions at a fixed limit: 8 badge-only sessions, visits and an agent community fill all 16; a badge open is refused; selecting a 17th connects and closes only the oldest badge-only session, never the selected or agent one; the closed one stays closed until selected, then connects.
  • Notification, community and shell suites pass, and the pre-push hook passed.
  • Gurney ran the live checks on commit 2 (two isolated relays, both themes). All passed except the relay-outage case, which commit 3 fixes. Commit 3 fixed that case, and the outage and recovery checks passed live on commit 3. Commit 4's outage and recovery checks passed live. The fifth commit fixes the row's ordinary-unread dot, which rendered 0×0 (an inline span with only a width and height). It is now inline-block, vertically centered. It also rewords one comment.
  • Commit 6 updates the browser journeys that assumed only the selected community connects:
    • The switcher helper and the scroll-intent journey accept the , unread in other communities label suffix.
    • Live recovery, presence (publisher lock, held setup), sidebar unread batching, thread history and Buzz-link inventory counts are scoped to the community under test. Subscription ids are matched per socket, since each community's socket numbers its own.
    • The inactive-community icon journey holds the background session and checks both icons load before it starts.
    • The icon-congestion journey's saved communities get modeled sessions with no live stream, so its foreground-capacity probe still runs.
    • The two-page Builderlab login journey keeps one community. In the browser harness every community's live stream is an HTTP/1.1 request to one same-origin dev broker, so two pages × two communities plus two EventSources exhaust Chromium's per-origin pool. The native app doesn't share that pool: its live traffic is a Rust WebSocket (relay_socket_connect) or a webview WebSocket, and its reads go through relay_http IPC.

Design system

The row badge reuses CountBadge. The dots use --text-navigation-unread, as the message-list unread dot does. The trigger dot is ringed in --bg-app. No new tokens.

@wpfleger96
wpfleger96 force-pushed the duncan/cross-community-unreads branch from 78ca79a to 82fb217 Compare November 13, 2026 17:13
@wpfleger96
wpfleger96 marked this pull request as ready for review November 13, 2026 18:13
@wpfleger96
wpfleger96 requested a review from a team as a code owner November 13, 2026 18:13

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Three actionable findings: browser broker starvation, native foreground socket exhaustion, and inaccurate background coverage documentation.

const { viewer, memberships, selected } = communities.snapshot();
if (selected) visited.add(selected);
const ids = viewer ? memberships.map((m) => m.id) : [];
const joined = new Set(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Reserve foreground capacity on the browser broker path

On a fresh HTTP/1.1 browser-development launch with six saved communities, background acquisition opens six persistent same-origin broker streams. Subsequent broker requests queue indefinitely: an actual-app Chromium check with the production broker and modeled upstream timed out a new identity request after 3 seconds without it reaching the broker. Two communities passed; six and eight failed. A build-only control suppressing background acquisition passed all three cases. Reserve capacity according to the active transport, or suppress browser background acquisition until streams can be multiplexed. Restore congestion coverage with real streams: the fixture's new live: !saved avoids this failure. This reproduction applies to the browser development host, not native IPC.

for (const id of joined) {
let entry = entries.get(id);
if (!entry) {
const relay = communities.open(id);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Account for retained sessions before admitting background connections

With 17 reachable saved FI communities and Personal space selected, startup retains the first eight. Visiting communities 9–17 then requires 17 native sockets, so the last foreground connection hits Native sockets are busy at src-tauri/src/federated_identity/socket.rs:84. Without the new background acquisition, those nine visits need only nine sockets. Communities.open/acquire retains sessions until leave/disposal; dropping an observer only unsubscribes. The session owner needs a total budget and foreground precedence, including release of eligible background-only sessions. Lowering this observer constant alone cannot guarantee capacity. Evidence is source tracing plus candidate observer execution with modeled retention/admission and a passing no-projection control; native IPC exhaustion was not executed.

Comment thread docs/communities.md Outdated
space. Personal space
clears selection without forgetting memberships. Its **Join a community** item opens the
existing join dialog; displaying saved communities reads relay metadata but does not open sessions for them.
existing join dialog. Displaying saved communities reads relay metadata; the app

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P3] Document the actual background observation limit

The documentation promises connection to every saved community, but the implementation observes only the first eight plus communities selected during this launch. An unvisited ninth community can therefore show no unread badge despite having unread activity. Document the cap and visit rule so readers do not interpret missing badges as complete coverage.

wpfleger96 and others added 8 commits November 13, 2026 15:29
Extract the Dock dot's community unread rule into observeCommunityUnread,
add the sidebar's attention count, and project it for every community with
an open session. Picker rows show a count or dot; the closed switcher shows
a dot while another community has unread.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Open each joined community's session in the background and start its unread
repair once, so communities not visited since launch are badged too. Drop
this commit to badge only communities already opened.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…is stale or unknown

The picker projection now publishes no state while a community's unread is
stale (its connection dropped, until the reconnect's read finishes) or no
counted channel has observed evidence. The Dock dot's rule is unchanged.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…nnection is down

Evidence can stay fresh while the WebSocket is down, since HTTP reads still
succeed. The picker projection now also follows the session's live status
and publishes no state unless it is connected. The Dock dot is unchanged.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
Journeys written for one connected community now scope their counts,
holds and locks to the community under test, accept the switcher's
elsewhere-unread label, and model the background sessions they start:
saved congestion communities answer without a live stream, the icon
journey holds the inactive session to prove icons need none, and the
two-page login journey keeps one community so its streams fit the
harness's single HTTP/1.1 origin.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…ket limit

The unread projection opened every joined community, and each session
holds one native relay socket; the host refuses a 17th, so background
sessions could starve the selected or an agent's community. It now opens
at most BACKGROUND_UNREAD_LIMIT (8), plus every community selected this
launch, which is retained already.

The browser fixture's quotas also apply to primary only: the
quota-refused roster was being spent on the secondary community's
startup read, so primary's first roster revoked access early and renewed
its agent observer before the recovery under test.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
…agent work

Background unread sessions never take the last native socket, and when the
socket limit is full the oldest badge-only session closes, without purging,
before a session opened for its own work connects. A closed badge stays blank
until that community is opened again.

The sidebar-unread overlap test now holds the app's 10s read deadline itself,
so a slow runner cannot cancel its held unread batch before release.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
@wpfleger96
wpfleger96 force-pushed the duncan/cross-community-unreads branch from 32a4d9b to 6d2d2c5 Compare November 13, 2026 20:30
wpfleger96 and others added 2 commits November 13, 2026 15:33
Will raised the native socket limit to 32 and background unread to 24.
On the browser development broker every session holds a same-origin
HTTP/1.1 stream, so it gets its own limit of two sessions and badge-only
sessions never take the connections ordinary requests need. The icon
congestion journey gets real live streams again and runs with six and
eight saved communities. The docs describe the background limit and
eviction.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
…iction

Eviction announced the closed badge before storing the session that
needed its socket, so a listener could reopen a badge into the freed
slot or build a second session for the same agent community. The
replacement is now stored first, then listeners are told.

Badge-only sessions now keep the last slot free, so the reserved socket
is never one still being released by a closed badge. The broker allows
three sessions to keep one badge plus that reserve. The sidebar-unread
overlap test suspends only 10s deadlines started while its read is held.

Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 One session-admission regression found.

Comment thread src/features/communities/service.ts Outdated
closed.add(oldest);
// Close before connecting so the native socket is free first.
room = dispose(oldest);
for (const fn of listeners) fn();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 [P2] Reserve the replacement before notifying observers

With three saved communities and two startup sessions, selecting the third invokes this notification before the replacement is registered. The real unread listener synchronously reenters open(id, true) and creates a badge session; the outer acquisition creates another session and overwrites its map/scope entry. Actual-app checks with the production broker reproduce two distinct streams and two modeled upstream sockets for the selected community in both Chromium and WebKit (expected one). A build-only control removing this early notification passes both engines. A source-level check wiring the real communities and unread services also shows that a subsequent foreground open can evict the selected community because the reentrant badge classification survives. Reserve the replacement session/scope and foreground ownership before notifying observers, and cover admission with the real unread listener wired in.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants