diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 3c6f1d5913..18730c9636 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -811,6 +811,22 @@ pub const fn is_identity_archive_request_kind(kind: u32) -> bool { matches!(kind, KIND_IA_ARCHIVE_REQUEST | KIND_IA_UNARCHIVE_REQUEST) } +/// Returns `true` if `kind` is a human-visible channel message. +/// +/// This is the set clients render as timeline content and count toward unreads +/// (`CHANNEL_MESSAGE_EVENT_KINDS` in Desktop, the equivalent Mobile set). It is +/// the authoritative trigger for DM resurfacing: a hidden DM must reappear when +/// any human-visible message arrives, regardless of which supported kind a +/// client uses. Reactions, edits, diffs, pins, deletions, and system messages +/// are deliberately excluded — they can land after the last real message and +/// must not resurface a conversation the recipient hid. +pub const fn is_human_visible_message_kind(kind: u32) -> bool { + matches!( + kind, + KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2 | KIND_FORUM_POST | KIND_FORUM_COMMENT + ) +} + /// Returns `true` if `kind` is a Buzz command kind that requires transactional execution. pub const fn is_command_kind(kind: u32) -> bool { matches!( @@ -913,6 +929,39 @@ mod tests { assert!(!is_relay_only_kind(KIND_NIP43_LEAVE_REQUEST)); } + #[test] + fn human_visible_message_kinds_cover_every_rendered_message() { + // Exactly the client `CHANNEL_MESSAGE_EVENT_KINDS` set — the set that + // must resurface a hidden DM. Forum posts/comments count: the relay + // accepts them as channel writes and both clients render them. + for kind in [ + KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_V2, + KIND_FORUM_POST, + KIND_FORUM_COMMENT, + ] { + assert!( + is_human_visible_message_kind(kind), + "kind {kind} must be human-visible" + ); + } + // Non-message activity in a channel must not resurface a hidden DM. + for kind in [ + KIND_REACTION, + KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_PINNED, + KIND_SYSTEM_MESSAGE, + KIND_DELETION, + KIND_FORUM_VOTE, + ] { + assert!( + !is_human_visible_message_kind(kind), + "kind {kind} must not be human-visible" + ); + } + } + #[test] fn parameterized_replaceable_range() { assert!(!is_parameterized_replaceable(29999)); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 352161286f..9fa4c30a02 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -3036,7 +3036,13 @@ async fn ingest_event_inner( }); } - let dm_message_sender = if matches!(kind_u32, KIND_STREAM_MESSAGE | KIND_STREAM_MESSAGE_V2) + // Resurface a hidden DM for every human-visible message kind, not just + // kinds 9/40002. The relay also accepts forum posts/comments (45001/45003) + // as channel-scoped writes, and both clients render them as timeline + // messages, so a custom or stale client posting one into a hidden DM must + // reopen it too. Reactions, edits, and system kinds stay excluded via the + // shared predicate. + let dm_message_sender = if buzz_core::kind::is_human_visible_message_kind(kind_u32) && channel_row .as_ref() .is_some_and(|channel| channel.channel_type == "dm") diff --git a/crates/buzz-test-client/tests/e2e_nostr_interop.rs b/crates/buzz-test-client/tests/e2e_nostr_interop.rs index ed342beabf..3e9845f5d8 100644 --- a/crates/buzz-test-client/tests/e2e_nostr_interop.rs +++ b/crates/buzz-test-client/tests/e2e_nostr_interop.rs @@ -1331,10 +1331,16 @@ async fn test_nipdv_hide_then_reopen_updates_snapshot() { /// the recipient's hidden set. The sender remains hidden if they independently /// closed the row: visibility is per viewer and delivery must not rewrite the /// author's own preference. +/// +/// Parameterized over every human-visible message kind the relay accepts as a +/// channel write (`is_human_visible_message_kind`): kinds 9/40002 plus forum +/// post/comment 45001/45003. A stale or custom client can post a forum event +/// into a DM, and both clients render it, so all four must resurface a hidden +/// DM. Dropping either forum kind from the ingest predicate fails this test. #[tokio::test] #[ignore] async fn test_nipdv_supported_message_kinds_resurface_hidden_dm_for_recipient() { - for kind in [9, 40002] { + for kind in [9u16, 40002, 45001, 45003] { let keys_a = Keys::generate(); let keys_b = Keys::generate(); let keys_c = Keys::generate();