mirror of
https://github.com/block/buzz.git
synced 2026-10-29 21:41:33 +00:00
## Problem Mentions — and thread replies that @-mention you — played the **Needs action** sound instead of the **@Mentions** sound. Reported by @morgmart: "I set Needs action to a different sound and it's the only one I ever hear." ## Root cause Two vocabularies got conflated in #475: - **Section / filter vocabulary** (plural): `mentions`, `needs_action`, `activity`, `agent_activity` — the shape of `FeedSections`, the `--types` filter, and the agent-facing CLI docs. - **Per-item category vocabulary** (singular for mention): `mention`, `needs_action`, `activity`, `agent_activity` — the `FeedItemCategory` contract in `desktop/src/shared/api/types.ts`, unchanged since #12. The Tauri feed builder reused the filter string `"mentions"` as each mention item's `category`. Only one word differs between the vocabularies, so only mentions broke. Every frontend consumer compares against the singular, so real mentions never matched and fell through to the resolver's `needs_action` fallback. The E2E mock bridge emits the singular form, so tests never saw the drift. ### Symptoms this fixes (all from the one mislabel) - Mentions and mentioning thread replies played the Needs-action sound - Mention notifications used the Needs-action title format - Mentions in muted channels were suppressed (the mute-bypass never fired) - Inbox / Home feed labelled mentions "Channel update" - Channel activity popover's mentions list was always empty ## Fix **Fix the owner, not the symptoms.** `FeedItemInfo.category` becomes a `FeedItemCategory` enum whose serde form is exactly the TS union, so a misspelled category can't compile at the producer. A serialization test pins each variant to its wire string. **Frontend:** `slotForFeedKind` maps every known category explicitly. The `needs_action` fallback for unknown categories is **kept on purpose** — a contract drift should cost the user the wrong sound, not a missed alert — but it now `console.warn`s so the drift is visible to developers instead of masquerading as intended behavior. `e2eBridge.ts` and `tauri.ts` now derive the category type from `types.ts` instead of retyping it. Not touched: the plural `--types` filter and `FeedSections` keys. Those are the section vocabulary and are correct as-is. ## Verification - `just ci` green (file-size ratchet, Rust/Tauri/desktop/mobile tests, desktop + web builds) - New tests: 2 Rust (`feed_item_category_serializes_to_frontend_contract`, `feed_item_from_event_carries_singular_mention_category`), 3 TS in `sound.test.mjs` incl. one that feeds the old `"mentions"` string and asserts fallback + warning - **Runtime, dev build against the production relay:** controlled test from an agent identity into a test channel — - mention in channel → @Mentions sound, inbox shows "Mentioned in" ✅ (was Needs-action) - thread reply with mention → @Mentions sound, once ✅ (was Needs-action) - plain thread reply in the channel being viewed → silent, as designed ✅ ## Reviewers - @tlongwell-block — #475 introduced the plural category; please confirm it wasn't intentional - @wesbillman — owner of the original `FeedItemCategory` contract (#12) and most of the feed builder - @taylorkmho — owner of the sound-slot model and resolver (#968); the fallback-with-warning shape is the part to weigh in on - cc @klopez4212 --------- Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
411 lines
13 KiB
Rust
411 lines
13 KiB
Rust
use std::collections::HashMap;
|
|
|
|
use serde::{Deserialize, Deserializer, Serialize};
|
|
|
|
#[derive(Serialize)]
|
|
pub struct IdentityInfo {
|
|
pub pubkey: String,
|
|
pub display_name: String,
|
|
/// Durable location of the active identity key.
|
|
pub storage: String,
|
|
/// True when the app booted with an ephemeral key because the OS keyring
|
|
/// was empty despite a prior successful migration (key was externally
|
|
/// deleted). The frontend routes to the nsec re-import step when true.
|
|
/// Mutually exclusive with `locked`.
|
|
pub lost: bool,
|
|
/// True when the app booted with an ephemeral key because the OS keyring
|
|
/// holding the identity is unreachable this boot (keyring locked or
|
|
/// unavailable). The real key still exists in the keyring; the frontend
|
|
/// shows a "unlock the keyring and relaunch" screen. Mutually exclusive
|
|
/// with `lost`.
|
|
pub locked: bool,
|
|
/// True when the boot-time Phase 2 reset attempted a wipe but verification
|
|
/// failed. Identity resolution was skipped; the frontend shows a
|
|
/// reset-failed recovery screen. The sentinel is preserved so the next
|
|
/// relaunch retries the wipe automatically.
|
|
pub reset_failed: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ProfileInfo {
|
|
pub pubkey: String,
|
|
pub display_name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub about: Option<String>,
|
|
pub nip05_handle: Option<String>,
|
|
pub owner_pubkey: Option<String>,
|
|
/// `true` when a real kind:0 event was found on the relay; `false` for the
|
|
/// synthesized fallback returned when no metadata event exists. The
|
|
/// onboarding gate uses this to distinguish "new user with no profile" from
|
|
/// "returning user whose display_name happens to be empty".
|
|
pub has_profile_event: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct UserProfileSummaryInfo {
|
|
pub display_name: Option<String>,
|
|
/// Kind-0 `name` field, carried separately from `display_name` so clients
|
|
/// can match @mention text against either alias (agents and the CLI
|
|
/// resolve mentions server-side against `display_name` *or* `name`).
|
|
#[serde(default)]
|
|
pub name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub nip05_handle: Option<String>,
|
|
pub owner_pubkey: Option<String>,
|
|
#[serde(default)]
|
|
pub is_agent: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct UsersBatchResponse {
|
|
pub profiles: HashMap<String, UserProfileSummaryInfo>,
|
|
pub missing: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct UserSearchResultInfo {
|
|
pub pubkey: String,
|
|
pub display_name: Option<String>,
|
|
pub avatar_url: Option<String>,
|
|
pub nip05_handle: Option<String>,
|
|
pub owner_pubkey: Option<String>,
|
|
#[serde(default)]
|
|
pub is_agent: bool,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct SearchUsersResponse {
|
|
pub users: Vec<UserSearchResultInfo>,
|
|
pub next_cursor: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct UserNoteInfo {
|
|
pub id: String,
|
|
pub pubkey: String,
|
|
pub created_at: i64,
|
|
pub content: String,
|
|
pub tags: Vec<Vec<String>>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct NoteReactionSummary {
|
|
pub note_id: String,
|
|
pub emoji: String,
|
|
pub count: usize,
|
|
pub pubkeys: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct UserNotesCursor {
|
|
pub before: i64,
|
|
pub before_id: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct UserNotesResponse {
|
|
pub notes: Vec<UserNoteInfo>,
|
|
pub next_cursor: Option<UserNotesCursor>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ChannelInfo {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub channel_type: String,
|
|
pub visibility: String,
|
|
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
|
|
pub description: String,
|
|
pub topic: Option<String>,
|
|
pub purpose: Option<String>,
|
|
pub member_count: i64,
|
|
#[serde(default)]
|
|
pub member_pubkeys: Vec<String>,
|
|
pub last_message_at: Option<String>,
|
|
pub archived_at: Option<String>,
|
|
#[serde(default)]
|
|
pub participants: Vec<String>,
|
|
#[serde(default)]
|
|
pub participant_pubkeys: Vec<String>,
|
|
#[serde(default = "default_true")]
|
|
pub is_member: bool,
|
|
pub ttl_seconds: Option<i32>,
|
|
pub ttl_deadline: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ChannelDetailInfo {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub channel_type: String,
|
|
pub visibility: String,
|
|
#[serde(deserialize_with = "deserialize_null_string_as_empty")]
|
|
pub description: String,
|
|
pub topic: Option<String>,
|
|
pub topic_set_by: Option<String>,
|
|
pub topic_set_at: Option<String>,
|
|
pub purpose: Option<String>,
|
|
pub purpose_set_by: Option<String>,
|
|
pub purpose_set_at: Option<String>,
|
|
pub created_by: String,
|
|
pub created_at: String,
|
|
pub updated_at: String,
|
|
pub archived_at: Option<String>,
|
|
pub member_count: i64,
|
|
pub topic_required: bool,
|
|
pub max_members: Option<i32>,
|
|
pub nip29_group_id: Option<String>,
|
|
pub ttl_seconds: Option<i32>,
|
|
pub ttl_deadline: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ChannelMemberInfo {
|
|
pub pubkey: String,
|
|
pub role: String,
|
|
#[serde(default)]
|
|
pub is_agent: bool,
|
|
/// Optional — kind:39002 events do not carry per-member join timestamps,
|
|
/// so this is `None` when populated from a NIP-29 members event.
|
|
#[serde(default)]
|
|
pub joined_at: Option<String>,
|
|
pub display_name: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ChannelMembersResponse {
|
|
pub members: Vec<ChannelMemberInfo>,
|
|
pub next_cursor: Option<String>,
|
|
}
|
|
|
|
/// Per-item classification of a home feed entry.
|
|
///
|
|
/// This is the wire contract for `FeedItem.category` in the desktop frontend
|
|
/// (`desktop/src/shared/api/types.ts`). It is distinct from the plural
|
|
/// *section* vocabulary (`mentions`, `needs_action`, …) used by
|
|
/// [`FeedSections`] and the `--types` filter: a mention item lives in the
|
|
/// `mentions` section but carries the singular `mention` category.
|
|
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
|
|
#[serde(rename_all = "snake_case")]
|
|
pub enum FeedItemCategory {
|
|
Mention,
|
|
NeedsAction,
|
|
Activity,
|
|
AgentActivity,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct FeedItemInfo {
|
|
pub id: String,
|
|
pub kind: u32,
|
|
pub pubkey: String,
|
|
pub content: String,
|
|
pub created_at: u64,
|
|
pub channel_id: Option<String>,
|
|
pub channel_name: String,
|
|
#[serde(default)]
|
|
pub channel_type: Option<String>,
|
|
pub tags: Vec<Vec<String>>,
|
|
pub category: FeedItemCategory,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct FeedSections {
|
|
pub mentions: Vec<FeedItemInfo>,
|
|
pub needs_action: Vec<FeedItemInfo>,
|
|
pub activity: Vec<FeedItemInfo>,
|
|
pub agent_activity: Vec<FeedItemInfo>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct FeedMeta {
|
|
pub since: i64,
|
|
pub total: u64,
|
|
pub generated_at: i64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct FeedResponse {
|
|
pub feed: FeedSections,
|
|
pub meta: FeedMeta,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct SearchHitInfo {
|
|
pub event_id: String,
|
|
pub content: String,
|
|
pub kind: u32,
|
|
pub pubkey: String,
|
|
pub channel_id: Option<String>,
|
|
pub channel_name: Option<String>,
|
|
pub created_at: u64,
|
|
pub score: f64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct SearchResponse {
|
|
pub hits: Vec<SearchHitInfo>,
|
|
pub found: u64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct SendChannelMessageResponse {
|
|
pub event_id: String,
|
|
pub parent_event_id: Option<String>,
|
|
pub root_event_id: Option<String>,
|
|
pub depth: u32,
|
|
pub created_at: i64,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ThreadSummary {
|
|
pub reply_count: u32,
|
|
pub descendant_count: u32,
|
|
pub last_reply_at: Option<i64>,
|
|
pub participants: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ForumMessageInfo {
|
|
pub event_id: String,
|
|
pub pubkey: String,
|
|
pub sig: String,
|
|
pub content: String,
|
|
pub kind: u32,
|
|
pub created_at: i64,
|
|
pub channel_id: String,
|
|
pub tags: Vec<Vec<String>>,
|
|
#[serde(default)]
|
|
pub thread_summary: Option<ThreadSummary>,
|
|
#[serde(default)]
|
|
pub reactions: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ForumPostsResponse {
|
|
pub messages: Vec<ForumMessageInfo>,
|
|
pub next_cursor: Option<i64>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ForumThreadReplyInfo {
|
|
pub event_id: String,
|
|
pub pubkey: String,
|
|
pub sig: String,
|
|
pub content: String,
|
|
pub kind: u32,
|
|
pub created_at: i64,
|
|
pub channel_id: String,
|
|
pub tags: Vec<Vec<String>>,
|
|
pub parent_event_id: Option<String>,
|
|
pub root_event_id: Option<String>,
|
|
pub depth: u32,
|
|
pub broadcast: bool,
|
|
#[serde(default)]
|
|
pub reactions: serde_json::Value,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ForumThreadResponse {
|
|
pub root: ForumMessageInfo,
|
|
pub replies: Vec<ForumThreadReplyInfo>,
|
|
pub total_replies: u32,
|
|
pub next_cursor: Option<String>,
|
|
}
|
|
|
|
/// Forward keyset pagination cursor for `get_thread_replies`.
|
|
///
|
|
/// Thread replies routinely share a `created_at` second (bursty threads), so
|
|
/// the cursor must carry the last reply's `event_id` as a tiebreak alongside
|
|
/// its `created_at`. A timestamp-only cursor advances past the entire tied
|
|
/// second after one page and silently drops every tied reply beyond the page
|
|
/// limit — the "missed messages" bug this read-path work fixes. The relay
|
|
/// keysets on `(event_created_at, event_id)` to match.
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct ThreadCursor {
|
|
/// `created_at` of the last reply already loaded (Unix seconds).
|
|
pub created_at: i64,
|
|
/// Hex event id of that last reply — the tiebreak within a shared second.
|
|
pub event_id: String,
|
|
}
|
|
|
|
/// Response for `get_thread_replies` — the full reply subtree under a root
|
|
/// event, fetched server-side from `thread_metadata` (NOT assembled from the
|
|
/// channel cache). `events` are raw Nostr events in chronological order;
|
|
/// `next_cursor` is the composite `(created_at, event_id)` of the last event
|
|
/// when a full page was returned, for forward keyset paging, else `None`.
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ThreadRepliesResponse {
|
|
pub events: Vec<serde_json::Value>,
|
|
pub next_cursor: Option<ThreadCursor>,
|
|
}
|
|
|
|
/// Composite backward keyset cursor for channel-timeline paging via the bridge
|
|
/// (`get_channel_messages_before`). The relay orders `created_at DESC, id ASC`
|
|
/// and advances past a tied second with `id > before_id`, so the event id is the
|
|
/// tiebreak that lets paging escape a second denser than one WS page —
|
|
/// the case a bare `until` cursor cannot advance through.
|
|
#[derive(Serialize, Deserialize, Clone)]
|
|
pub struct ChannelPageCursor {
|
|
/// `created_at` of the last (oldest) message already loaded (Unix seconds).
|
|
pub created_at: i64,
|
|
/// Hex event id of that message — the `before_id` tiebreak within a second.
|
|
pub event_id: String,
|
|
}
|
|
|
|
/// Response for `get_channel_messages_before` — one keyset page of top-level
|
|
/// channel history, oldest-last (relay order: `created_at DESC, id ASC`).
|
|
/// `next_cursor` is the composite key of the last (oldest) event when a full
|
|
/// page was returned, else `None`.
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ChannelMessagesPageResponse {
|
|
pub events: Vec<serde_json::Value>,
|
|
pub next_cursor: Option<ChannelPageCursor>,
|
|
}
|
|
|
|
fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result<String, D::Error>
|
|
where
|
|
D: Deserializer<'de>,
|
|
{
|
|
Ok(Option::<String>::deserialize(deserializer)?.unwrap_or_default())
|
|
}
|
|
|
|
fn default_true() -> bool {
|
|
true
|
|
}
|
|
|
|
/// Response payload for `get_channels`. When the caller supplies a hash that
|
|
/// matches the computed stable hash, `channels` is `None` so the multi-MB
|
|
/// channel list is not serialized across IPC. `last_messages` is always
|
|
/// included — it is cheap and changes frequently (every new message).
|
|
#[derive(Serialize)]
|
|
pub struct GetChannelsPayload {
|
|
pub hash: String,
|
|
/// `None` on a not-modified response (hash matched); `Some` with the full
|
|
/// sorted list otherwise.
|
|
pub channels: Option<Vec<ChannelInfo>>,
|
|
/// Map of channel id → ISO-8601 timestamp of its most recent message.
|
|
/// Empty for channels with no messages.
|
|
pub last_messages: std::collections::HashMap<String, String>,
|
|
}
|
|
|
|
// ── Social / Contact list ───────────────────────────────────────────────────
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ContactListResponse {
|
|
pub id: String,
|
|
pub pubkey: String,
|
|
pub created_at: i64,
|
|
pub tags: Vec<Vec<String>>,
|
|
pub content: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize)]
|
|
pub struct ContactEntry {
|
|
pub pubkey: String,
|
|
#[serde(default)]
|
|
pub relay_url: Option<String>,
|
|
#[serde(default)]
|
|
pub petname: Option<String>,
|
|
}
|