Skip to content

fix(dm/private-rooms): auto-scroll, sync-window UX, banned-state, processed-invite gate, same-second revive#286

Merged
sanity merged 4 commits into
mainfrom
fix/dm-ui-polish-batch
May 18, 2026
Merged

fix(dm/private-rooms): auto-scroll, sync-window UX, banned-state, processed-invite gate, same-second revive#286
sanity merged 4 commits into
mainfrom
fix/dm-ui-polish-batch

Conversation

@sanity
Copy link
Copy Markdown
Contributor

@sanity sanity commented May 18, 2026

Problem

Five user-impacting UI issues in the DM / private-room surfaces. All UI-only, no contract or delegate WASM changes, no migration entry required.

Approach

Per-issue:

Testing

cargo test -p river-ui --features example-data,no-sync — 165 passing (10 new tests).

New regression tests:

Also verified:

  • cargo check -p river-ui --target wasm32-unknown-unknown --features no-sync clean.
  • cargo clippy --workspace no new warnings introduced.
  • cargo fmt --check clean.
  • DM thread modal Playwright tests (3) still pass against the new build.

Test plan

Closes #267 #279 #280 #283 #284

[AI-assisted - Claude]

sanity and others added 4 commits May 18, 2026 10:27
Bundles five user-impacting UI fixes; all UI-only, no contract or
delegate WASM changes, no migration entry required.

* #283 — Auto-scroll in DM thread modal didn't fire after PR #278's
  Codex round-1 fix removed the reactive read from `use_effect`.
  Mirrored the conversation.rs pattern: the last bubble's onmounted
  updates a `Signal<Option<Rc<MountedData>>>` and the effect reads
  it as a subscribing call so it re-fires on new bubble mounts.

* #284 — Encrypted-message placeholder during the invite-acceptance
  sync window (new private-room invitee, no secrets yet) was alarming
  ("[Encrypted message - secret vN not available (have: [])]"). When
  the secrets map is empty render "Decrypting your invitation — this
  should only take a moment..."; when SOME secrets are present but
  not this version, render a neutral diagnostic.

* #267 — Same-second / clock-skewed inbound DM didn't revive a
  hidden thread (filter rule is strict `<=`). Added an explicit
  `unhide_dm_thread` call from the inbound delta path in
  `apply_delta_inner`, symmetric with the existing outbound-send
  unhide in `dm_thread_modal::do_send`.

* #279 — In-app invite-accept on a stale invite card re-opened the
  full nickname-prompt flow for a previously-LeaveRoom'd room. The
  `PRESENT_INVITATION_REQUEST` bridge effect now gates on
  `is_invitation_processed`, matching the URL-bar and click-
  interceptor paths.

* #280 — Banned user saw an ENABLED "Accept invitation" button on
  the invite card because `already_member = can_participate().is_ok()`
  collapsed Banned and NotMember to the same false. Replaced the
  boolean with a `InviteCardState::{Joinable, AlreadyMember, Banned}`
  enum; the Banned variant disables the button with destructive
  styling and "You're banned from this room".

Regression tests added:
* `auto_scroll_effect_subscribes_to_last_dm_bubble_signal` +
  `dm_bubble_exposes_last_bubble_sink_prop` — source-text pins so a
  future refactor that "cleans up" the unused-looking signal can't
  silently re-break #283.
* `invite_card_state_classifies_*` (5 tests) — pure-function pins
  for the new `classify_invite_card_state` helper covering each
  match arm + the "must distinguish banned from not-a-member"
  invariant.
* `decrypt_placeholder_for_empty_secrets_is_friendly` +
  `decrypt_placeholder_for_missing_version_is_neutral_not_alarming`
  — pin both #284 placeholder branches.
* `apply_delta_inner_revives_hidden_thread_for_inbound_dm_sender`
  — source-text pin for the #267 wiring in apply_delta_inner.
* `dm_accept_bridge_gates_on_is_invitation_processed` — source-text
  pin for the #279 gate in the bridge effect.

Closes #267 #279 #280 #283 #284

[AI-assisted - Claude]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…iew)

Self-review finding: my apply_delta_inner change collected
`inbound_dm_senders` from the RAW delta. But the contract's
`direct_messages::apply_delta` silently drops re-deliveries (via
signature dedup against `existing_sigs`). On a network re-sync of an
already-seen DM, the raw delta still carries the entry — and my code
would have called `unhide_dm_thread` for it, un-archiving a thread
the user had just hidden.

Fix: snapshot pre-merge DM signatures inside `with_mut`, then after
the merge diff against the post-merge `direct_messages.messages` set
to find the DMs that genuinely just landed. Filter to recipient ==
self_member_id so we only unhide for inbound (to-us) DMs.

Updated the source-text pin test to assert both the snapshot variable
and the post-merge collection variable exist, so a future refactor
can't silently revert to the raw-delta approach.

[AI-assisted - Claude]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Codex P2 finding on PR #286: the delta-path unhide alone leaves the
same #267 case reachable when DMs arrive via `update_room_state_inner`
(full-state GET refresh after sleep, resubscription, or initial sync).
A new inbound DM whose timestamp falls on `hidden_at_ts` would leave
the thread archived even though a new message had arrived.

Mirror the apply_delta_inner approach on the full-state path: capture
pre-merge DM signatures BEFORE the `with_mut` borrow, diff against
post-merge `direct_messages.messages` inside `with_mut` to find
genuinely new inbound DMs (filtered to recipient == self_member_id),
then after the borrow drops queue an `unhide_dm_thread` call per
unique sender. Identical shape to the delta path.

Added a `update_room_state_inner_also_revives_hidden_thread_for_inbound_dm`
source-text pin that splits the file at the function signature and
asserts the snapshot + collection + unhide call all appear AFTER it,
so the test catches a regression on EITHER path independently.

[AI-assisted - Claude]

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…p R1 M1)

Skeptical R1 M1: the `secrets.is_empty()` branch fires on EVERY cold-
start of an established private room, not just on fresh invitations.
`secrets` is `#[serde(skip)]`, so each reload of the tab hits the
empty-map state until `repopulate_secrets_from_state` rehydrates it
from the encrypted blobs. The previous "Decrypting your invitation"
wording was therefore factually wrong for established-member reloads
— users on cold-start would see "your invitation" copy for messages
in a room they joined months ago.

Fix: change the wording to "Decrypting messages — this should only
take a moment...". Accurate for both first-time joiners (who really
ARE waiting on a delegate back-fill) AND established members
reloading the tab. The existing test pin
`decrypt_placeholder_for_empty_secrets_is_friendly` still passes
against the new wording.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@sanity sanity merged commit e09b5dd into main May 18, 2026
5 checks passed
@sanity sanity deleted the fix/dm-ui-polish-batch branch May 18, 2026 16:20
sanity added a commit that referenced this pull request May 18, 2026
- #282 (--signing-key-file flag): fixes recurring rooms.json drift
  for owner ops on the same machine.
- #286 (UI polish bundle): #283 auto-scroll regression, #284 sync-
  window placeholder UX, #267 same-second DM revive, #279 processed-
  invite gate, #280 banned-card state.

riverctl 0.1.61 published to crates.io alongside.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.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.

bug(dm): same-second inbound DM stays hidden after hide cutoff

1 participant