feat(communities): add right-click actions to the community rail - #400
Conversation
Each saved community in the rail now has a context menu (right-click, the ContextMenu key or Shift+F10, labelled "Actions for <name>") built from the shared ContextMenu primitives, in the original's order: Mark all as read, then Copy community URL, Invite to community and Community settings. - Copy community URL writes the canonical HTTPS origin and reports through the host toast stack, including clipboard failures. - Mark all as read uses a new `markAllChannelsRead()` on the unread capability, which serialises `markChannelRead` over the channels that still show unread evidence or a local mark and finishes the sweep past a failing channel. The item is enabled only for the selected community's ready session while read state can sync; elsewhere it stays visible but disabled with a note saying why. - Invite to community shows only on the selected community when the relay-signed roster names the viewer an owner or admin, and never in native builds, which cannot mint invites. It opens the Invites settings card scoped to that community. The rail reads the roster and derives the viewer's role through a new `useCommunityRole` hook. - Community settings opens Settings scoped to that community's origin, selecting it on the way through the host's navigation. Opening a menu or running any item never acquires an inactive session; the rail test covers this alongside the menu, and the shell, communities and unread docs describe the new actions and their gating. Typecheck, lint and the full unit suite (420 files, 5098 tests) pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to the community rail context menu (07abd7d), addressing the review's four suggestions. - Both ways of opening a community's menu (right-click, and the ContextMenu key or Shift+F10) now go through one `openMenu` helper, so the roster refresh that gates Invite to community runs for keyboard opens too instead of leaving a stale role until the next right-click. - The open uses a functional state update that keeps an existing anchor. A browser that synthesises a contextmenu event for Shift+F10 re-enters the open through Base UI; the keyboard anchor now survives that instead of the menu jumping to the synthesised pointer coordinates. - Closing the menu forces focus back to the rail button only when the menu was opened from the keyboard. For pointer opens `finalFocus` returns `true`, so Base UI restores whatever had focus before, and a right-click while typing no longer moves the caret to the rail. The keyboard/pointer distinction lives in a ref mirrored from the menu state, since that state is already null when the closing popup asks. - `markAllChannelsRead()` re-checks the per-channel grant at each channel's turn and skips channels no longer allowed, rather than letting a grant revoked mid-sweep surface as "Couldn't mark everything as read". Like a grant that arrives mid-sweep, it waits for the next explicit action. Tests: the rail test gains cases for the keyboard open re-reading the roster, the keyboard anchor surviving a re-entrant contextmenu event, and a pointer open restoring focus to a text field rather than the rail; the existing keyboard test also asserts the menu is anchored beside the rail. The unread test harness gains `holdCommit()`, which holds a save after its change is applied but before it resolves (like a storage transaction still committing when relay events arrive), and a case that revokes a grant during the sweep's first write and expects one saved result and no error. Each new test was checked to fail against the previous behaviour. The moderation plugin's Invites card registration is deliberately untouched and remains a separate follow-up. The shell and unread docs describe the focus and grant behaviour. Typecheck, lint and the full unit suite (420 files, 5102 tests) pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Leaving a saved community now confirms in an alert dialog, publishes a signed NIP-43 leave request (kind 28936) to that community's relay by origin, and only then forgets it on this device: the membership is dropped, a left selection falls back to Personal space, the retained session is disposed, and the origin+viewer partitions of view state, channel heads, read state, outbox, quick reactions and channel setups are purged. A relay answering that the viewer is not a member counts as already absent and removes the community with an informational toast; any other refusal or an unreachable relay keeps the membership with an error toast so the item can be tried again. The development broker gains a scoped `leave` route, the native adapter a matching `leave` request, and the Rust signer allowlists only the empty protected shape of kind 28936. Opening the menu or leaving never acquires an inactive community's session. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Follow-up to Leave community in the rail menu (ca92ffe), addressing the review's three warnings and three suggestions. - A relay that answers "blocked: you are banned from this community" has already severed access at authentication, before any leave handler runs, so no retry could ever succeed. `requestLeave` now resolves it as `access-revoked` alongside `already-absent` (both via a new `settledRefusal()` in the shared protocol module), and the rail removes the community from this device with an informational toast saying access was revoked, instead of keeping a membership the viewer could never remove. - Failures before and after the relay accepted the leave read differently. A pre-publish failure keeps the connection-oriented retry message. Once `requestLeave` has resolved, a failing `communities.leave` reports that the community was left but this device couldn't finish cleaning up, and that leaving it again finishes (through the not-a-member answer). To keep that promise accurate, the service's required save is now the only step that throws, before anything changes; a session that will not dispose is reported rather than thrown, since the membership is already gone by then. - When the left community was the selected one, the rail routes the fallback to Personal space through the host's `onSelect` callback after the service has updated the store, so the leave gets the same channels navigation and ingress-recovery check as clicking Personal space and the viewer is not left on a Settings card scoped to a gone community. - After leaving an inactive community, focus follows the selection: the still-selected community's button, or Personal space only where a left selection actually landed. Focus still returns to the community itself after a cancel or a failure that kept it. - Purge failures are no longer discarded. `purgeCommunityDeviceState` names each store, logs every failure with `console.warn` including the store and origin, and `communities.leave` returns the list so the rail appends "Some saved data couldn't be cleared." to the success toast when any occurred. - Removing a session scope guards the `indexOf` result, so a miss can never splice another community's scope off the teardown list. Tests: the rail test gains the banned answer in the settled-refusal table, a post-publish device failure that shows the cleanup message and not the connection one, host selection of Personal space for a left selection (and none for an inactive one), the residual-data toast line, and the inactive-leave case now expects focus on the still-selected community. The device-state and service tests assert the named, console-warned failures; the native adapter test covers the banned classification. Each new or adjusted test was checked to fail against the previous sources. The communities and shell docs describe the settled answers, the two failure messages, the host-routed fallback and focus placement. Typecheck, lint and the full unit suite (421 files, 5132 tests) pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…leanup toast Follow-up to the settled leave edges (ac3315b), addressing the review's warning and three of its suggestions. The Invites card gating in native builds stays a separate change; the moderation plugin is untouched. - The banned answer no longer purges. A ban on the relay can be timed (the ban handler accepts an expiration) or lifted (kind 9041), and the relay keeps the membership while a ban is active, so the viewer is not absent and the drafts and reading positions keyed by that origin and viewer may be wanted again. `communities.leave` gains a `purge` option; the rail passes `purge: false` for `access-revoked` and `true` for the accepted and not-a-member answers. The banned outcome still removes the membership and disposes any retained session, and its informational toast now says the viewer is currently banned so the leave was refused, the community was removed from this device, and it can be added again by its URL if access is restored, promising nothing about permanence. The protocol and API comments drop the "no retry can ever succeed" premise. - Only the `communities.leave` call sits inside the rail's post-publish try/catch. The host `onSelect(null)` fallback and the success toast run after it, so a host callback that throws while navigating can never produce the "couldn't finish cleaning up" toast; it is logged with `console.error` as the host's own failure and the leave still reports success, since the device did finish. The caught save error is no longer discarded: the service now attaches the storage error as the `cause` of its "Could not save this community" error, and the rail puts that error's own words in the toast, so a persistently unwritable store in a native build is visible instead of an identical "leave it again to finish" promise on every attempt. - The re-entrant open that some browsers synthesise for Shift+F10 no longer reads the roster a second time: `openMenu` calls `onMenuOpen` only while the menu is closed in the render closure, which React has refreshed by the time the synthesised contextmenu event arrives. The first anchor still wins, as before. - The session dispose failure in `communities.leave` has its own console message instead of the purge wording; it is still reported, not thrown. `purgeFailure` is no longer exported, since nothing else used it. Tests: the rail's settled-refusal table asserts which answers purge and carries the new banned toast; the keyboard-anchor case expects the duplicate roster read to be skipped while the anchor stays beside the rail; the save-failure case carries a cause and expects it named, with a sibling case for a cause-less error; a new case has the host callback throw after a successful leave and expects the success toast, the console error and Personal space selected. The service test gains the `purge: false` path (membership and session gone, view state, channel and quick reactions kept, and found again after a re-add) and a native-mode leave whose `localStorage.setItem` throws, which must reject with the storage error as its cause and leave the snapshot, persisted record, retained session and device state untouched with nothing warned, then finish once the store saves. Every new or adjusted test was checked to fail against the previous sources; the native contract test was also checked against a build that drops the session before the save. `device-state.test.ts` needed no change: the purge itself is unchanged. The communities and shell docs describe the three outcomes and which of them purge, the named storage error and the host-callback boundary. Typecheck, lint and the full unit suite (421 files, 5136 tests) pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
Resolves the item deferred across the last three reviews: the bundled moderation plugin registered its Invites card unconditionally and `CommunityAdmin` called `mintInvite` with no native gate, while the rail hid Invite to community in native builds through `inviteMintingAvailable()`. The card is more than a minting surface: it shows owners and admins the relay-signed member roster, which reads fine through the packaged adapter. So the card stays registered in every build and gates its controls instead. In native builds the Invite to community button and its dialog are absent and a note says this build can't create invites or change members, so the member list is read-only here. The per-member promote/demote/remove menus are gated too, which is wider than the review's wording. The packaged adapter carries no `member` route any more than an `invite` one (native-api.ts answers "This operation is unavailable on the packaged connection" for both), so offering those commands natively could only end in that generic error. The gate's doc comment now names both routes. The plugin reuses the rail's predicate rather than a second copy. `src/bundled/moderation/api.ts` re-exports `inviteMintingAvailable` from `src/features/communities/api`, the module it already imports `communityRequest` from, so no new module boundary is crossed and Biome's import restrictions are unaffected. Paths to the card other than the rail item: the Settings sidebar's Communities group, restored history entries and `buzz://open?target=` locators can all name `buzz.moderation/invites`. Keeping the card registered means each of them lands on the read-only roster in native builds; had the registration been gated instead, `src/app/navigation.ts` would have reported the section unavailable, as it still does for a genuinely missing card. Nothing else references the section key. Tests: a new `CommunityAdmin.native.test.tsx` runs the card as a native build (Tauri present, desktop platform, packaged adapter mocked to answer only the session contract, fetch stubbed to throw). Its roster case expects three members listed, the note, no Invite to community button, no per-member action menus, a working Refresh, no request reaching the adapter and no broker request; it fails against the previous sources, which render the button. Its registration case pins `apply` registering the card natively; that behaviour is unchanged, so this case passes on both. The existing web-build cases in `CommunityAdmin.test.tsx` continue to find the button and mint. The communities, shell and identity docs describe the shared gate, the read-only card and the missing broker routes. Typecheck, lint and the full unit suite (422 files, 5138 tests) pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
The moderation plugin's settings card became `buzz.moderation/membership`
("Membership") upstream in #348, so the rail's Invite to community item
still pointed at the removed `buzz.moderation/invites` section and would
land on an unavailable section. Point it at the Membership card and bring
the comments and the communities and shell docs in line with the card's
new name and its Invite members button. `useCommunityRole` now serves only
the rail, since the card reads the shared membership store instead.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
0f8507a to
e50aafa
Compare
wesbillman
left a comment
There was a problem hiding this comment.
One integration issue is detailed inline. Star Lord’s automated source review via Wes’s account; head e50aafa7a5c631cc596c8b9ac83ebd83753daf1f, base 5d2b08e2ff3bb40dc62f04015298ff319f4a4f8a. No PR code, tests, or app workflows were executed locally. Hosted CI is failing: the inline finding explains the repeated session-request assertions in both browsers and the scroll measurement; the separate WebKit panel-resize failure remains unattributed.
| community: string, | ||
| signal?: AbortSignal, | ||
| ): Promise<Member[] | null> { | ||
| const author = await relayAuthor(community); |
There was a problem hiding this comment.
[P2] Reuse the selected session’s relay authority. The new rail hook runs this on initial connection, community switches, and menu opens, adding a /session GET each time even though RelaySession.relayAuthor already holds the authority. This breaks existing integration checks: navigation-sidebar.spec.mjs:693 records two primary requests instead of one in both engines, and scroll.spec.mjs:140 records nine requests instead of two (CI run). The fixture counts contract requests, so this is not evidence of extra retained connections. Include relayAuthor in RosterReader and use the captured session’s authority while keeping the roster read fresh; preserve the existing connection-count assertions.
kalvinnchau
left a comment
There was a problem hiding this comment.
🤖 Two nonduplicate findings below: pointer-dismissal focus restoration and a missing registration-contract regression check. The existing roster-authority finding is not repeated.
| // A keyboard open came from the rail, so focus goes back there. A | ||
| // pointer open may have interrupted typing elsewhere; Base UI's | ||
| // default restores whatever was focused before. | ||
| return fromKeyboard.current ? (button.current ?? false) : true; |
There was a problem hiding this comment.
🤖 [P2] Restore the interrupted focus target after pointer dismissal
Focus the Message #Alpha composer, right-click Secondary, then press Escape. In both Chromium and WebKit, after the menu disappears, focus lands on BUTTON[aria-label="Switch to Secondary"] rather than returning to the composer, interrupting continued typing. Returning true uses Base UI’s trigger-oriented default instead of preserving the pre-pointer target. The existing jsdom test does not reproduce the browser’s pointer focus transfer. Capture the connected focused element before the right-pointer interaction moves focus and restore it on pointer dismissal; retain the rail-button target for keyboard dismissal. Add a real-browser regression that asserts initial composer focus and waits for menu removal before checking restored focus.
| import styles from "./Communities.module.css"; | ||
|
|
||
| /** The bundled moderation plugin's Membership card, addressed by contribution key. */ | ||
| export const MEMBERSHIP_SECTION = "buzz.moderation/membership"; |
There was a problem hiding this comment.
🤖 [P3] Check the Membership section key against the real registration
The rail hard-codes the moderation contribution key, while CommunityRail.test.tsx:464 checks against this same constant rather than the registered card. A card rename can therefore leave Invite targeting an unregistered settings section while the rail test still passes; the final commit’s renamed-Membership correction already demonstrates this drift. Add an integration assertion that this key resolves against the actual moderation registration so future registration changes cannot silently break Invite navigation.
…store interrupted focus Addresses the three review comments on #400. - The rail's roster read asked the broker for the session contract on every read (initial connection, community switches and menu opens) to learn the relay signing key, although the selected session already holds it as `relayAuthor`. That extra GET broke the connection-count assertions in navigation-sidebar.spec.mjs:693 (two `primary` sessions instead of one, in both engines) and scroll.spec.mjs:140 (nine instead of two). `RosterReader` now carries `relayAuthor`, `readRoster` verifies the roster against it and throws "Community authority unavailable" when the session has none, and `useCommunityRole` takes only the session and viewer. The dead `relayAuthor` helper and its re-export from the moderation plugin's api module are removed. - Dismissing a menu opened by pointer left focus on the rail button. Browsers focus the button on the right-click's mousedown, before the contextmenu event opens the menu, so Base UI's default "previous focus" was the button rather than the composer the click interrupted. The rail item now remembers the focused element at pointerdown, ahead of that move, and returns focus to it while it is still connected; with nothing interrupted, or the field gone, Base UI's default still lands on the rail button. Keyboard opens keep returning to the community. - The rail hard-codes the Membership card's contribution key, so a card rename could leave Invite to community pointing at an unregistered section while the rail's own test still passed. A new registration test in the moderation plugin runs the plugin's real `apply` under the id from its manifest against a real SettingsCardsService and asserts the rail's key resolves to a visible card titled Membership, the same check Settings navigation makes. Tests: the rail harness no longer answers the broker session route, its session carries `relayAuthor`, and the roster cases assert no session request is made; a new case with no relay authority shows no Invite and reads nothing. The pointer-focus case now simulates the browser's focus move (pointerdown, then focus on the button) before the contextmenu event and adds the field-gone fallback; it fails against the previous item. menu-dismiss.spec.mjs gains a real-browser regression: focus the Message #Alpha composer, right-click Secondary, press Escape and expect the composer focused, then a Shift+F10 open returning to the rail button. It fails against the previous item in Chromium (focus lands on the button) and passes with the fix in Chromium and WebKit. The two connection-count specs pass again locally in both engines and in the chromium-measurements project. The shell and communities docs describe the session-held authority and the restored focus target. The WebKit layout.spec.mjs:398 panel-resize failure in the same CI run is unrelated: the identical failure appears in the failed WebKit journey jobs of ten other branches on 2026-09-29/30 while main stayed green. Typecheck, lint and the full unit suite (458 files, 5614 tests) pass. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
wesbillman
left a comment
There was a problem hiding this comment.
No further changes requested in this follow-up: the session-request finding is addressed by reusing the selected session’s authority, and the revision also repairs interrupted focus and checks the real Membership registration. Star Lord’s automated source review via Wes’s account; head 7696dbf43dfbb49af5c6e36b85c4d87905ee185f, base 5d2b08e2ff3bb40dc62f04015298ff319f4a4f8a. Source-only: no PR code, tests, or app workflows were executed locally. The CI snapshot had passing browser measurements, but JavaScript and several browser shards were still running; this is not a full-CI or runtime sign-off.
* origin/main: (27 commits) Let plugin pages publish NIP-AR artifacts and embed the host thread view (#434) test(app): migrate entity-navigation test off removed buzz://open locator API (#463) Show agent activity in navigation (#423) test(browser): hold motion when it commits, not on its start event (#459) fix(navigation): ignore unknown query parameters on Buzz links and remove the buzz://open locator (#457) feat(design-system): distinguish controls on floating surfaces (#429) feat(native): add community extras and media preparation (#450) Clone inventory identities through reviewed text and fresh identity creation (#289) feat(communities): add right-click actions to the community rail (#400) fix(messages): keep a send reveal pending until its scroll runs (#454) fix(messages): reserve a stable scrollbar gutter on the channel feed (#451) fix(sidebar): list plugin pages as sidebar rows via an opt-in primary flag (#401) feat(channels): surface canvas content in channel settings (#426) fix(profiles): remove redundant presence status row (#394) test(browser): count live retries once the page handles startup controls (#443) feat(composer): host-owned resource links for the Projects picker (#445) feat: support native read state and recent channel activity (#444) feat(native): serve relay media and uploads in packaged builds (#433) feat(channels): suggest joined channels in the composer (#446) feat: support native agent activity, library, memories, and community resolution (#441) ... Signed-off-by: Codex <noreply@openai.com>
Summary
Every saved community in the rail now has a context menu. Open it with a right-click, the ContextMenu key or Shift+F10; it's labelled "Actions for ". The items come in the original's order:
markAllChannelsRead()on the unread capability. It runsmarkChannelReadone channel at a time, only over channels that still show unread evidence or a local mark. If one channel fails, the sweep still finishes the rest and then rethrows the first failure. A channel whose grant is revoked mid-sweep is skipped rather than reported as an error. The item is enabled only for the selected community's ready session, and only while read state can sync. Otherwise it stays visible but disabled, with a note saying why.Opening a menu or running any item never acquires an inactive session.
Supporting changes
features/communities/roster.tsand a shareduseCommunityRolehook. The Invites card now uses the same hook, so both places gate owner/admin actions the same way. The moderation API re-exports the moved symbols.openMenupath, so keyboard opens also refresh the roster. If a browser synthesises a contextmenu event for Shift+F10, the keyboard anchor is kept.AppShellgains anonOpenTargetprop, wired toservices.navigation.open.Testing
holdCommit()for this.🤖 Generated with Claude Code