Files
buzz/desktop/src-tauri/src/models.rs
morgmart db5617dd15 fix(desktop): emit singular mention feed category so alerts route correctly (#6665)
## 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>
2026-08-24 10:20:53 -07:00

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>,
}