fix(dm/private-rooms): auto-scroll, sync-window UX, banned-state, processed-invite gate, same-second revive#286
Merged
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
use_effect. New messages no longer scrolled the viewport.[Encrypted message - secret vN not available (have: [])]placeholder for every message until the owner's delegate published the back-fill ciphertext.hidden_at_ts), the rail filter's strict-<=rule kept the thread hidden. Same-second outbound-send was already handled by an explicitunhide_dm_threadcall; inbound wasn't.PRESENT_INVITATION_REQUESTbridge effect inapp.rsdidn't gate onis_invitation_processed. Clicking Accept on a stale invite card for a previously-LeaveRoom'd room re-presented the full nickname-prompt flow.already_member = can_participate().is_ok()collapsedBannedandNotMemberto the samefalse. Banned users saw an ENABLED "Accept invitation" button that would silently fail on submit.Approach
Per-issue:
last_dm_bubble: Signal<Option<Rc<MountedData>>>. The LAST bubble'sonmountedwrites through a newlast_bubble_sinkprop onDmBubble. The auto-scrolluse_effectreadslast_dm_bubble()as a subscribing call so it re-fires whenever a new bubble mounts.OUTBOUND_SEND_COUNTER.peek()stays non-reactive (re-entrancy-safe); the bubble mount provides the trigger.secrets.is_empty():"Decrypting your invitation — this should only take a moment..."(sync-window UX);"[Encrypted message - secret vN unavailable]"(rotated-past case);(have: [...])diagnostic dump entirely from user-facing copy.apply_delta_inner, collect the senders of any inbound DMs fromdelta.direct_messages.new_messages, then after the merge lands callunhide_dm_thread(owner_vk, sender)per unique non-self peer. Pairs with the existing outbound-send unhide so both directions revive symmetrically. The strict-<=filter rule itself stays unchanged (the existingis_thread_hidden_equal_timestamp_stays_hiddentest depends on it for the hide-then-look case).is_invitation_processed(&inv.to_encoded_string()). If already processed, silently no-op (the rail already shows the room if the user is still a member; the click-interceptor uses the same handling).already_member: boolfield onInviteCardDatawith a three-wayInviteCardState::{Joinable, AlreadyMember, Banned}enum, populated via a newclassify_invite_card_state(Option<Result<(), SendMessageError>>)pure helper. The Banned variant renders a destructive-tinted disabled button reading "You're banned from this room".Testing
cargo test -p river-ui --features example-data,no-sync— 165 passing (10 new tests).New regression tests:
auto_scroll_effect_subscribes_to_last_dm_bubble_signal+dm_bubble_exposes_last_bubble_sink_prop— source-text pins for fix(dm): auto-scroll in DM thread modal no longer fires after PR #278's Codex round-1 fix #283's wiring so a future cleanup of the unused-looking signal can't silently re-break it.invite_card_state_classifies_already_member/…_classifies_banned/…_classifies_not_member_as_joinable/…_classifies_room_not_loaded_as_joinable/invite_card_state_distinguishes_banned_from_not_a_member— pure-function pins for the new classifier.decrypt_placeholder_for_empty_secrets_is_friendly+decrypt_placeholder_for_missing_version_is_neutral_not_alarming— pin both fix(private-rooms): replace alarming "secret v0 not available" placeholder during invite-acceptance sync window #284 placeholder branches.apply_delta_inner_revives_hidden_thread_for_inbound_dm_sender— source-text pin for the bug(dm): same-second inbound DM stays hidden after hide cutoff #267 wiring inapply_delta_inner.dm_accept_bridge_gates_on_is_invitation_processed— source-text pin for the fix(invite): in-app accept re-shows full nickname flow for previously-accepted-then-left rooms #279 gate.Also verified:
cargo check -p river-ui --target wasm32-unknown-unknown --features no-syncclean.cargo clippy --workspaceno new warnings introduced.cargo fmt --checkclean.Test plan
Closes #267 #279 #280 #283 #284
[AI-assisted - Claude]