Skip to content

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

@sanity

Description

@sanity

Problem

Found during Codex review of PR #265 (issue #261).

DM timestamps are stored in whole Unix seconds. The hide-filter uses strict <=:

// common/src/chat_delegate.rs::is_thread_hidden
hidden_threads
    .iter()
    .find(|h| &h.room_owner_vk == room_owner_vk && h.peer == peer)
    .is_some_and(|h| max_message_ts <= h.hidden_at_ts)

This is correct for the message that seeded hidden_at_ts — that exact message must not revive the thread it was used to dismiss. But it has a corner case: if a peer sends an inbound DM in the same Unix second the user clicked Hide, max_message_ts == hidden_at_ts and the thread stays hidden. The peer's message is genuinely new (different content), but the filter treats it as "the message that was already there when I hid."

The outbound path explicitly works around this race by calling unhide_dm_thread after a successful send (dm_thread_modal::do_send, Codex P1 fix during PR #265 review). The inbound path has no equivalent — so a same-second inbound DM gets stranded until a later-second DM arrives.

Reproduction

  1. User has a DM thread with peer; latest message at ts=N.
  2. User clicks Hide → hidden_at_ts = N.
  3. Peer sends a DM that lands with timestamp = N (any time within the same Unix second).
  4. Rail filter checks N <= N → true → thread stays hidden.
  5. The unread DM does NOT surface in the rail. It only surfaces when a later-second DM arrives.

Probability: real but narrow — the peer's message needs to land within the same Unix second the user clicked Hide. On a busy thread, low but non-zero. On a quiet thread the user is intentionally dismissing, also low but non-zero.

Suggested fix

Mirror the outbound-revival path. When the room synchronizer applies an inbound DM:

  • For each new inbound DM message (room, peer, ts):
    • If HIDDEN_DM_THREADS has an entry for (room, peer) with hidden_at_ts == ts, call unhide_dm_thread(room, peer).

This is symmetric with dm_thread_modal::do_send's call after save_outbound_dm. The tombstone path (RECENTLY_UNHIDDEN) already handles the cross-session race.

Alternative: change hidden_at_ts to store last_seen_ts and use strict <. Wire-format-breaking — requires a V2 store struct + migration, so the symmetric-unhide-on-inbound fix is cheaper.

Severity

P2 (Codex's own rating). Hidden DMs are recoverable — any later-second DM revives the thread, and the user can also click the peer in the members list to open the thread directly. But for the duration of the same-second window, a notification-worthy inbound DM is silently swallowed.

References

[AI-assisted - Claude]

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions