feat(ui): make Share Invite the primary invite action, demote invite links - #567
feat(ui): make Share Invite the primary invite action, demote invite links#567sanity wants to merge 3 commits into
Conversation
|
Note for whoever rebases this: #565 and this PR will collide in one specific place. #565 adds Landing order doesn't matter for correctness, only for who does the inventory edit. #565 is the smaller diff and is already out of draft, so it going first seems right. [AI-assisted - Claude] |
…links An invite link embeds `invitee_signing_key` — a private key — and is good for exactly ONE person. People share one link with several people, and everyone who used it then holds the same identity, so none of them work. That was the members panel's only accent-filled action, so it was what everyone clicked. River already had a route that cannot fail that way: DM an invitation card the recipient accepts in one click, nothing copied anywhere (#252, #457). It was undiscoverable, and it pointed the wrong way — it starts from a person in the CURRENT room and asks which of your OTHER rooms to invite them to, so someone in room X wanting to invite Alice to room X had to navigate to another room where Alice is a member first. - "Share Invite" is now the panel's primary action. It opens a new contact picker running the other direction: room fixed (the one being viewed), pick who to send it to from members of your other rooms. Search filters by person or room; people you've DM'd sort first; the list is capped at 40 rows and says how many it withheld. - The link flow is demoted to a small secondary button beside "Export ID", not removed — it is still the only way to invite someone who is not on River yet. - Link-modal copy is blunter ("contains a private key", "one person only") and names Share Invite by its actual label. Both pickers now share one send path, `invite_dm.rs`'s `compose_and_send_invite_dm`, so an invitation minted from one entry point cannot drift from the other. Tests: 5 unit tests for the candidate sort/filter (including that the comparator is total, so rows can't swap between renders when two members share a nickname); a source pin that the DM route stays primary and the link button never gets the accent fill back; a 6-case browser spec for order, computed styling, picker contents, filtering and arming Send. Also fixes a pre-existing spec bug: responsive-layout's two sandboxed- iframe tests hardcoded http://localhost:8082, silently ignoring PLAYWRIGHT_BASE_URL, so running the suite on any other port (which AGENTS.md tells you to do) failed them for reasons unrelated to the change under test. Closes #566 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rgazoUgaN4V3vYWq99F1n
Promoting the contact picker to the primary invite action created a route that BYPASSES the member list. Until now the only way to reach the DM-invite flow went through that list, so a user had already been shown the ⚠ before choosing anyone. The picker lists members of rooms the user may never have opened, so without the badge the primary invite action became the one surface where an imitator's name renders clean — and picking the wrong "Amy Gonzalez" sends a stranger an invitation to your room. Each row now carries the same warning the member list does, computed from the same helpers, with the checker and badge map built once per candidate room rather than per peer. The row's aria-label repeats the warning, because an explicit aria-label replaces element content for naming, so a badge rendered inside a labelled button is invisible to a screen reader. The member ID is on hover — the remedy the tooltip names. `impersonation_warning_is_wired_into_every_render_surface` now covers this as the fourth surface, including the argument pin (passing `self_id` instead of `peer` compiles and flags the genuine owner and every real moderator). Also adds the call-site pin `each_picker_passes_the_carrier_room_first_and_ the_target_room_third`: the two pickers hand the same two rooms to `compose_and_send_invite_dm` in opposite positions, and swapping them at either site compiles. A swap signs the invitation against the room the DM is merely travelling through and sends the DM into the room the recipient is not a member of yet, so the user is told "the recipient is no longer a member of this room" about a room they can see them in. No unit test or browser spec can reach it (the send needs the chat delegate), so the argument names are pinned. Rows are addressed by `data-person` / `data-room` rather than by parsing the accessible name, which now carries warning prose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rgazoUgaN4V3vYWq99F1n
56248f1 to
e090311
Compare
#565 landed while this was in review and added `components.rs::volatile_value_binding_audit`, which pins the exact set of `value:` bindings across `ui/src`. This branch adds two — the contact picker's search `input` and its personal-message `textarea` — so the audit failed on the merge commit even though the branch was green on its own. Both already satisfy #565's rule: each binds `oninput` writing the same local `use_signal` its `value:` reads, which is the documented exception for controlled inputs (deferring that write lags the DOM by a `setTimeout(0)` and drops keystrokes, the very bug #564 was). Verified by mutation: dropping the search field's `oninput` fails `every_editable_value_binding_tracks_input` at the exact line. So this is an inventory entry, not a fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018rgazoUgaN4V3vYWq99F1n
Problem
The members panel's primary action was Invite Member, which mints an invite link. That link embeds
invitee_signing_key— a private key — and is good for exactly one person. In practice people share one link with several people; everyone who used it then holds the same identity, and none of them work.Ian, 2026-07-29:
River already had a route with none of that failure mode — DM an invitation card the recipient accepts in one click, no credential leaving River (#252, #457). But it was undiscoverable and pointed the wrong way: it starts from a person in the room you are viewing and asks which of your other rooms to invite them to. Somebody standing in room X wanting to invite Alice to room X had to first navigate to another room where Alice is a member, click her name there, and pick room X from a list. So the only discoverable invite path was the one that breaks when shared.
Approach
1. "Share Invite" becomes the primary action (accent-filled, first in the panel). It opens a new contact picker running the direction users actually arrive from: the room is fixed (the one being viewed) and you pick who, from the members of your other rooms. Search filters by person or by room ("someone from the team room" is how people remember a contact); people you have DM history with sort first; the list caps at 40 rows and states how many it withheld.
2. The link flow is demoted, not removed — a small secondary "Invite by link" button beside "Export ID". It is still the only way to invite somebody who is not on River yet. Its
data-testidis unchanged so existing specs keep pointing at the link flow they were written for.3. Copy is blunter about what the link is ("contains a private key", "one person only", "the most common way invites go wrong") and names Share Invite by the label it actually carries.
Why a second picker rather than a mode on the existing one: the two run in opposite directions and each has non-trivial per-open invariants (session-tagged selection, generation-gated watchdog). Two components with one shared send path keeps each set of invariants local. The shared path is
invite_dm.rs::compose_and_send_invite_dm, so an invitation minted from one entry point cannot drift from the other.Impersonation warning (second commit)
Promoting the picker created a route that bypasses the member list. Until now every path to the DM-invite flow went through that list, so the user had already been shown the ⚠ before choosing anyone. The picker lists members of rooms the user may never have opened, so without the badge the primary invite action became the one surface where an imitator's name renders clean — and picking the wrong "Finn Clarke" sends a stranger an invitation to your room.
Each row now carries the same warning, from the same helpers, with the checker and badge map built once per candidate room rather than per peer. The row's
aria-labelrepeats the warning, because an explicitaria-labelreplaces element content for naming — a badge inside a labelled button is invisible to a screen reader otherwise. Member ID is on hover, which is the remedy the tooltip names.Testing
Unit (
cargo test -p river-ui --bins, 805 pass): 5 tests for the candidate sort/filter, including that the comparator is total — two members of one room can share a nickname, and a non-total comparator lets rows swap between renders.Source pins:
share_invite_is_the_primary_invite_action_and_the_link_flow_is_secondary— order, which handler each button opens, and that the link button never getsbg-accentback.impersonation_warning_is_wired_into_every_render_surfaceextended to a fourth surface, including the argument pin (self_idforpeercompiles and flags the genuine owner and every real moderator).each_picker_passes_the_carrier_room_first_and_the_target_room_third— the two pickers hand the same two rooms tocompose_and_send_invite_dmin opposite positions and a swap compiles. A swap signs the invitation against the room the DM is merely travelling through and sends the DM into the room the recipient is not in yet, so the user is told "the recipient is no longer a member of this room" about a room they can see them in. Nothing else can reach it — the send needs the chat delegate, which is absent underno-sync— so the argument names are pinned.Browser (
invite-priority.spec.ts, 7 cases × 5 projects): button order and that the two really differ in computed background; the picker's header names the target room and no candidate row is from it; Send arms only after a pick; filtering by person and by room, and the no-matches state; the ⚠ badge with its tooltip, aria text and member-ID hover; close; and that the link flow still opens.Full suite: 752 passed, 23 skipped, 0 failed across chromium, firefox, webkit, mobile-chrome, mobile-safari.
Mutation-tested (11 mutations, each caught by the test named): dropping the DM-history sort priority, dropping the member-id tiebreak, filtering on person only, reporting 0 withheld, giving the link button the accent fill back, pointing Share Invite at the link modal, removing Share Invite's accent, swapping carrier/target at either call site, computing the warning from
self_id, and — the case that justifies the browser spec — suppressing the badge while every source pin still matches.Incidental fix
responsive-layout.spec.ts's two sandboxed-iframe tests hardcodedhttp://localhost:8082, silently ignoringPLAYWRIGHT_BASE_URL. Running the suite on any other port — whichAGENTS.mdtells you to do, since 8082 is shared between worktrees — pointed the iframe at whatever build happened to be on 8082, or at nothing, and failed those two for reasons unrelated to the change under test.Not in scope
No WASM, contract, or delegate change — UI only, so no migration.
Closes #566
[AI-assisted - Claude]