Skip to content

feat(communities): add right-click actions to the community rail - #400

Merged
wesbillman merged 8 commits into
mainfrom
community-rail
Sep 30, 2026
Merged

wesbillman merged 8 commits into
mainfrom
community-rail

Conversation

@matt2e

@matt2e matt2e commented Sep 29, 2026

Copy link
Copy Markdown
Contributor

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:

  • Mark all as read: uses a new markAllChannelsRead() on the unread capability. It runs markChannelRead one 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.
  • Copy community URL: writes the canonical HTTPS origin and reports the result, including clipboard failures, through the host toast stack.
  • Invite to community: shown only on the selected community, and only when the relay-signed roster names the viewer an owner or admin. It never appears in native builds. It opens the Invites settings card scoped to that community.
  • Community settings: opens Settings scoped to that community's origin through the host's navigation, which selects the community on the way.

Opening a menu or running any item never acquires an inactive session.

Supporting changes

  • The roster parsing and role derivation move out of the moderation plugin into features/communities/roster.ts and a shared useCommunityRole hook. 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.
  • Pointer and keyboard opens share one openMenu path, so keyboard opens also refresh the roster. If a browser synthesises a contextmenu event for Shift+F10, the keyboard anchor is kept.
  • On close, focus goes back to the rail button only for keyboard opens. After a pointer open, Base UI restores whatever had focus before, so right-clicking while typing doesn't pull the caret away.
  • AppShell gains an onOpenTarget prop, wired to services.navigation.open.
  • The communities, shell-design and unread docs describe the new actions and their gating.

Testing

  • New rail tests cover:
    • menu gating
    • that inactive sessions are never acquired
    • keyboard opens re-reading the roster
    • the keyboard anchor surviving a re-entrant contextmenu event
    • focus restoration after a pointer open
  • A native rail test checks that Invite is hidden.
  • New unread tests cover the sweep, including a grant revoked during the first write. The harness gains holdCommit() for this.
  • Typecheck, lint and the full unit suite (420 files, 5102 tests) pass.

🤖 Generated with Claude Code

matt2e and others added 7 commits September 30, 2026 14:05
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>
@matt2e
matt2e marked this pull request as ready for review September 30, 2026 06:43
@matt2e
matt2e requested review from a team, comp615 and wesbillman as code owners September 30, 2026 06:43

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

Comment thread src/features/communities/roster.ts Outdated
community: string,
signal?: AbortSignal,
): Promise<Member[] | null> {
const author = await relayAuthor(community);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[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 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.

🤖 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;

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] 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";

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] 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 wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@wesbillman
wesbillman merged commit 1b30cb3 into main Sep 30, 2026
20 checks passed
@wesbillman
wesbillman deleted the community-rail branch September 30, 2026 14:50
TheSentinel454 pushed a commit that referenced this pull request Sep 30, 2026
* 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>
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.

3 participants