From 4bf8da8115453ee64fafc0685206171a448eaa2e Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 25 Apr 2026 11:29:28 -0400 Subject: [PATCH 1/7] chore(ui): add right-edge fade + hidden scrollbar to category row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The category row already had \`overflow-x-auto whitespace-nowrap\` so it scrolled horizontally on narrow viewports — but with no visual cue that it was scrollable, "Ending Soon" looked like it was simply being clipped. Two small additions: - Hide the default scrollbar (\`[scrollbar-width:none]\` + the webkit-scrollbar pseudo-class). The OS scrollbar in this row is noise. - Right-edge fade gradient via a \`::after\` pseudo-element on a new positioning wrapper. Communicates "more content this way" the moment the row overflows, without a hamburger commitment. Pure progressive enhancement — at full width the fade sits over empty space and is invisible. Hamburger / bottom-nav / mobile pass is tracked in memory as its own future deep-dive once we actually build for phones. --- src/components/layout/TopShell.tsx | 61 +++++++++++++++++------------- src/style.css | 17 +++++++++ 2 files changed, 51 insertions(+), 27 deletions(-) diff --git a/src/components/layout/TopShell.tsx b/src/components/layout/TopShell.tsx index 2f25647a..0f9f3f3a 100644 --- a/src/components/layout/TopShell.tsx +++ b/src/components/layout/TopShell.tsx @@ -581,33 +581,40 @@ function CategoryBar() { return (
-
- {filteredCategories.map((category) => { - const active = activeCategory === category; - const icon = categoryIcon(category); - return ( - - ); - })} - {/* Help button — hidden until content is ready */} + {/* Outer wrap establishes a positioning context for the + right-edge fade pseudo-element. The inner row scrolls + horizontally; the fade is a hint that there's more + content beyond the cut-off when the viewport is too + narrow to show every category at once. */} +
+
+ {filteredCategories.map((category) => { + const active = activeCategory === category; + const icon = categoryIcon(category); + return ( + + ); + })} + {/* Help button — hidden until content is ready */} +
diff --git a/src/style.css b/src/style.css index 4b64c9e9..fdc1946e 100644 --- a/src/style.css +++ b/src/style.css @@ -576,6 +576,23 @@ button:focus-visible, animation: deadcat-notification-pulse 2s ease-out both; } +/* Right-edge fade indicator for the horizontally-scrolling category + row. The buttons themselves get clipped flush against the + container edge when the viewport is too narrow to show all of + them; the fade is a visual hint that there's more content to + scroll to. Pointer-events:none lets clicks pass through to the + button underneath. */ +.category-bar-scroll::after { + content: ""; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 32px; + pointer-events: none; + background: linear-gradient(to right, rgba(2, 6, 23, 0), #020617); +} + /* Trending carousel card content transition. Triggered by a `key` change on the wrapping div so each new featured item remounts and plays the keyframe. Combines a small translate with scale + fade From df73773c3128dfa741a9463c2bbda4a412fdb486 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 25 Apr 2026 11:47:25 -0400 Subject: [PATCH 2/7] feat(notifications): scope to deadcat-authored content via NIP-89 client tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users who bring an existing Nostr identity to Deadcat were seeing their entire Nostr notifications stream in the bell — every reaction, zap, and reply across the whole network, most of it about content they posted from other clients. Two changes solve this: 1. **Universal NIP-89 client tag** on every event the app publishes. Previously only kind:1111 comments carried the tag; now reactions, deletions, follow lists, mute lists, profile updates, wallet backups, market announcements, attestations, pool announcements, limit orders, and order deletions all do too. Centralized via a shared `client_tag()` helper in the SDK so future builders can't forget it. 2. **Notifications subscription filters target events.** The subscription bundles a second filter (`{authors: [me]}`) alongside the original notifications filter, post-filters incoming events for the deadcat client tag, and tracks the resulting event IDs in an in-memory set. Inbound notifications (kind:7 / 9735 / 1111) are dropped unless their target `e` tag is in that set — i.e., unless they target a deadcat-authored event. Profile-level zaps (kind:9735 with no `e` tag) still pass through since they target the user directly. `parse_notification_event` takes a new `&HashSet` parameter; empty set opts out of filtering (used implicitly while the tracker is hydrating on the first burst of historical events). Existing test updated to seed the set with the parent comment id. No new persistence — the tracker rebuilds on every startup from the relay's historical replay. Persistence is a clean follow-up if the cold-start race window becomes an issue in practice; today's mitigation is that historical own-events typically arrive before historical notifications targeting them. --- .../deadcat-sdk/src/discovery/attestation.rs | 1 + .../deadcat-sdk/src/discovery/comments.rs | 12 ++-- .../deadcat-sdk/src/discovery/market.rs | 1 + .../crates/deadcat-sdk/src/discovery/mod.rs | 16 +++++ .../crates/deadcat-sdk/src/discovery/pool.rs | 1 + .../deadcat-sdk/src/discovery/reactions.rs | 2 + .../deadcat-sdk/src/discovery/social.rs | 6 +- .../crates/deadcat-sdk/src/discovery/zaps.rs | 1 + src-tauri/crates/deadcat-sdk/src/lib.rs | 2 + src-tauri/src/commands.rs | 55 ++++++++++++++-- src-tauri/src/discovery.rs | 11 +++- src-tauri/src/notifications.rs | 64 +++++++++++++++++-- 12 files changed, 149 insertions(+), 23 deletions(-) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/attestation.rs b/src-tauri/crates/deadcat-sdk/src/discovery/attestation.rs index d0bd14c6..3c5417da 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/attestation.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/attestation.rs @@ -59,6 +59,7 @@ pub fn build_attestation_event( ), Tag::custom(TagKind::custom("outcome"), vec![outcome_str.to_string()]), Tag::custom(TagKind::custom("network"), vec![network_tag.to_string()]), + super::client_tag(), ]; Ok(EventBuilder::new(APP_EVENT_KIND, &content).tags(tags)) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/comments.rs b/src-tauri/crates/deadcat-sdk/src/discovery/comments.rs index 836c8bc4..a04acdc6 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/comments.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/comments.rs @@ -176,14 +176,9 @@ fn build_comment_tags( vec![network_tag.to_string()], )); - // NIP-89 client tag — lets other Nostr clients show "posted via - // Deadcat.live" on the comment. Handler coordinate + relay hint - // are intentionally omitted until we publish a kind:31990 - // handler; the plain two-element form is accepted everywhere. - tags.push(Tag::custom( - TagKind::custom("client"), - vec!["Deadcat.live".to_string()], - )); + // NIP-89 client tag — universal across every deadcat-authored + // event, see `super::client_tag` for rationale. + tags.push(super::client_tag()); Ok(tags) } @@ -233,6 +228,7 @@ pub fn build_comment_deletion_event( TagKind::custom("k"), vec![COMMENT_KIND.as_u16().to_string()], ), + super::client_tag(), ]; Ok(EventBuilder::new(Kind::Custom(5), "delete comment") .tags(tags) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/market.rs b/src-tauri/crates/deadcat-sdk/src/discovery/market.rs index 60efb39d..5748588c 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/market.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/market.rs @@ -105,6 +105,7 @@ pub fn build_announcement_event( Tag::hashtag(CONTRACT_TAG), Tag::hashtag(&category_lower), Tag::custom(TagKind::custom("network"), vec![network_tag.to_string()]), + super::client_tag(), ]; Ok(EventBuilder::new(APP_EVENT_KIND, &content).tags(tags)) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/mod.rs b/src-tauri/crates/deadcat-sdk/src/discovery/mod.rs index 4a377b11..b81090c5 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/mod.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/mod.rs @@ -62,6 +62,20 @@ pub const DEFAULT_RELAYS: &[&str] = &[ pub const DEFAULT_SOURCE_NPUB: &str = "npub1deadcat0qanhns8rxp7tqz8h0vptf2a8d7cvkfjfnkwcflrkh8nq0p9tyj"; +/// NIP-89 client name applied to every deadcat-authored event so +/// other Nostr clients can show "posted via Deadcat.live" and so +/// we can identify our own events on relays via a `#client` filter +/// (used by the notifications-filter cold-start backfill). +pub const CLIENT_NAME: &str = "Deadcat.live"; + +/// NIP-89 client tag for every event published by the app. +/// Two-element form (no kind:31990 handler coordinate) — accepted +/// universally and avoids tying us to a specific handler event we +/// haven't published yet. +pub fn client_tag() -> Tag { + Tag::custom(TagKind::custom("client"), vec![CLIENT_NAME.to_string()]) +} + // --------------------------------------------------------------------------- // Re-exports: market // --------------------------------------------------------------------------- @@ -239,6 +253,7 @@ fn build_order_tags( vec!["true".to_string()], )); } + tags.push(client_tag()); tags } @@ -466,6 +481,7 @@ pub fn build_order_deletion_request_event( Tag::hashtag(ORDER_DELETE_TAG), Tag::hashtag(market_id), Tag::custom(TagKind::custom("network"), vec![network_tag.to_string()]), + client_tag(), ]; Ok(EventBuilder::new(Kind::Custom(5), "delete limit order announcement").tags(tags)) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/pool.rs b/src-tauri/crates/deadcat-sdk/src/discovery/pool.rs index eac6d15a..e9637dec 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/pool.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/pool.rs @@ -306,6 +306,7 @@ pub fn build_pool_event( Tag::hashtag(POOL_TAG), Tag::hashtag(&canonical_market_id), Tag::custom(TagKind::custom("network"), vec![network_tag.to_string()]), + super::client_tag(), ]; Ok(EventBuilder::new(APP_EVENT_KIND, &content).tags(tags)) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/reactions.rs b/src-tauri/crates/deadcat-sdk/src/discovery/reactions.rs index 3ee7358c..5c83acb3 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/reactions.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/reactions.rs @@ -71,6 +71,7 @@ pub fn build_reaction_event( Tag::event(event_id), Tag::public_key(target_author), Tag::custom(TagKind::custom("k"), vec![target_kind.to_string()]), + super::client_tag(), ]; Ok(EventBuilder::new(REACTION_KIND, trimmed) @@ -93,6 +94,7 @@ pub fn build_reaction_deletion_event( TagKind::custom("k"), vec![REACTION_KIND.as_u16().to_string()], ), + super::client_tag(), ]; Ok(EventBuilder::new(Kind::Custom(5), "delete reaction") .tags(tags) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/social.rs b/src-tauri/crates/deadcat-sdk/src/discovery/social.rs index 3b7a2464..f6e45fa0 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/social.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/social.rs @@ -199,7 +199,7 @@ pub fn build_follow_list_event( follows: &[String], legacy_content: &str, ) -> Result { - let mut tags = Vec::with_capacity(follows.len()); + let mut tags = Vec::with_capacity(follows.len() + 1); for hex in follows { // Validate each pubkey so we never persist a malformed tag // — a single bad tag can make some relays reject the whole @@ -207,6 +207,7 @@ pub fn build_follow_list_event( PublicKey::from_hex(hex).map_err(|e| format!("invalid follow pubkey {hex}: {e}"))?; tags.push(Tag::custom(TagKind::p(), vec![hex.clone()])); } + tags.push(super::client_tag()); Ok(EventBuilder::new(FOLLOW_LIST_KIND, legacy_content) .tags(tags) .build(author)) @@ -219,7 +220,8 @@ pub fn build_mute_list_event( public: &[MuteEntry], encrypted_content: &str, ) -> UnsignedEvent { - let tags: Vec = public.iter().map(|entry| entry.tag()).collect(); + let mut tags: Vec = public.iter().map(|entry| entry.tag()).collect(); + tags.push(super::client_tag()); EventBuilder::new(MUTE_LIST_KIND, encrypted_content) .tags(tags) .build(author) diff --git a/src-tauri/crates/deadcat-sdk/src/discovery/zaps.rs b/src-tauri/crates/deadcat-sdk/src/discovery/zaps.rs index 338105c7..304dcc5b 100644 --- a/src-tauri/crates/deadcat-sdk/src/discovery/zaps.rs +++ b/src-tauri/crates/deadcat-sdk/src/discovery/zaps.rs @@ -78,6 +78,7 @@ pub fn build_zap_request_event( if let Some(coord) = req.event_coordinate { tags.push(Tag::custom(TagKind::custom("a"), vec![coord.to_string()])); } + tags.push(super::client_tag()); Ok(EventBuilder::new(ZAP_REQUEST_KIND, req.content) .tags(tags) diff --git a/src-tauri/crates/deadcat-sdk/src/lib.rs b/src-tauri/crates/deadcat-sdk/src/lib.rs index 03fdecba..b25a256c 100644 --- a/src-tauri/crates/deadcat-sdk/src/lib.rs +++ b/src-tauri/crates/deadcat-sdk/src/lib.rs @@ -104,6 +104,7 @@ pub use discovery::{ // Types AttestationContent, AttestationResult, + CLIENT_NAME, COMMENT_KIND, CONTRACT_TAG, CommentParent, @@ -158,6 +159,7 @@ pub use discovery::{ build_reaction_deletion_event, build_reaction_event, build_zap_request_event, + client_tag, connect_client, deserialize_private_mutes, discovered_market_to_contract_params, diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index bc74a0a7..880af1f8 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4502,7 +4502,17 @@ async fn run_notifications_subscription( let client = node.discovery().client().clone(); - let filter = nostr_sdk::Filter::new() + // Two filters in one subscribe call so both streams arrive on + // the shared `notifications()` channel: + // 1. Inbound notification candidates (kinds 7/9735/1111 + // p-tagging us). + // 2. Our own authored events — any kind. We post-filter to + // Deadcat-tagged ones in the channel handler because + // nostr-sdk 0.38's `custom_tag` only takes single-letter + // tag keys, and the NIP-89 `client` tag isn't single-letter. + // Authoring our own events doesn't generate a lot of + // traffic so the post-filter cost is negligible. + let inbound = nostr_sdk::Filter::new() .kinds([ nostr_sdk::Kind::Custom(1111), nostr_sdk::Kind::Custom(7), @@ -4510,9 +4520,10 @@ async fn run_notifications_subscription( ]) .pubkey(my_pubkey) .since(nostr_sdk::Timestamp::from_secs(resume_since)); + let own_events = nostr_sdk::Filter::new().author(my_pubkey); client - .subscribe(vec![filter], None) + .subscribe(vec![inbound, own_events], None) .await .map_err(|e| format!("subscribe: {e}"))?; log::info!( @@ -4520,17 +4531,36 @@ async fn run_notifications_subscription( &my_pubkey_hex[..8] ); + // In-memory set of event IDs the viewer has authored through + // Deadcat. Hydrated as own-event-filter results stream in; used + // to filter notifications targeting non-Deadcat content. + let mut own_deadcat_event_ids: std::collections::HashSet = + std::collections::HashSet::new(); + // The relay pool pushes every matched event through the shared - // `notifications()` broadcast channel. Non-notification events - // (discovery, etc.) also flow through — our `parse_notification_event` - // returns None for anything that isn't kind 1111/7/9735 we care about. + // `notifications()` broadcast channel. Discovery events also + // flow through — `parse_notification_event` returns None for + // anything that isn't kind 1111/7/9735 we care about. let mut notifications = client.notifications(); while let Ok(notif) = notifications.recv().await { let nostr_sdk::RelayPoolNotification::Event { event, .. } = notif else { continue; }; - let Some(record) = crate::notifications::parse_notification_event(&event, &my_pubkey_hex) - else { + + // Track our own deadcat-authored events so we can filter + // notifications targeting non-Deadcat content. The relay + // returns historical own-events on subscribe, so the set + // populates quickly without requiring a separate fetch. + if event.pubkey == my_pubkey && event_has_deadcat_client_tag(&event) { + own_deadcat_event_ids.insert(event.id.to_hex()); + continue; + } + + let Some(record) = crate::notifications::parse_notification_event( + &event, + &my_pubkey_hex, + &own_deadcat_event_ids, + ) else { continue; }; if store.insert(record) { @@ -4542,6 +4572,17 @@ async fn run_notifications_subscription( Ok(()) } +/// True when the event carries the NIP-89 `client = "Deadcat.live"` +/// tag we add to every outgoing event. +fn event_has_deadcat_client_tag(event: &nostr_sdk::Event) -> bool { + event.tags.iter().any(|tag| { + let fields = tag.as_slice(); + fields.len() >= 2 + && fields[0] == "client" + && fields[1].eq_ignore_ascii_case(deadcat_sdk::CLIENT_NAME) + }) +} + /// Return the live notification store, lazy-initializing it from the /// current network's JSON file on first use. Used by the four Tauri /// commands below and also shared with the background subscription diff --git a/src-tauri/src/discovery.rs b/src-tauri/src/discovery.rs index 6a660d90..e59373a3 100644 --- a/src-tauri/src/discovery.rs +++ b/src-tauri/src/discovery.rs @@ -195,6 +195,7 @@ pub fn build_wallet_backup_event( Tag::identifier(d_tag), Tag::custom(TagKind::custom("encrypted"), vec!["true".to_string()]), Tag::custom(TagKind::custom("encryption"), vec!["nip44".to_string()]), + deadcat_sdk::client_tag(), ]; EventBuilder::new(APP_EVENT_KIND, encrypted_content) @@ -258,6 +259,7 @@ pub fn build_backup_empty_replacement(keys: &Keys) -> Result { let tags = vec![ Tag::identifier(WALLET_BACKUP_D_TAG), Tag::custom(TagKind::custom("deleted"), vec!["true".to_string()]), + deadcat_sdk::client_tag(), ]; EventBuilder::new(APP_EVENT_KIND, "") @@ -274,7 +276,10 @@ pub fn build_backup_deletion_event(keys: &Keys) -> Result { keys.public_key(), WALLET_BACKUP_D_TAG, ); - let tags = vec![Tag::custom(TagKind::custom("a"), vec![coordinate])]; + let tags = vec![ + Tag::custom(TagKind::custom("a"), vec![coordinate]), + deadcat_sdk::client_tag(), + ]; EventBuilder::new(Kind::Custom(5), "delete wallet backup") .tags(tags) @@ -291,10 +296,11 @@ pub const RELAY_LIST_KIND: Kind = Kind::Custom(10002); /// Build a kind 10002 event with relay `r` tags. pub fn build_relay_list_event(keys: &Keys, relays: &[String]) -> Result { - let tags: Vec = relays + let mut tags: Vec = relays .iter() .map(|url| Tag::custom(TagKind::custom("r"), vec![url.clone()])) .collect(); + tags.push(deadcat_sdk::client_tag()); EventBuilder::new(RELAY_LIST_KIND, "") .tags(tags) @@ -461,6 +467,7 @@ pub async fn publish_profile( let content = meta.to_string(); let event = EventBuilder::new(Kind::Metadata, content) + .tags(vec![deadcat_sdk::client_tag()]) .sign(keys) .await .map_err(|e| format!("failed to sign profile event: {e}"))?; diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 0308cb98..359d5009 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -307,15 +307,45 @@ fn zap_receipt_sender(event: &Event) -> Option<(String, Option)> { /// Turn a raw inbound Nostr event into a notification record. Returns /// `None` when the event isn't notification-worthy — either it's the -/// viewer's own event, it doesn't actually target them, or its shape -/// doesn't match a supported kind. -pub fn parse_notification_event(event: &Event, my_pubkey_hex: &str) -> Option { +/// viewer's own event, it doesn't actually target them, its shape +/// doesn't match a supported kind, or it targets an event the viewer +/// didn't author through Deadcat. +/// +/// `own_deadcat_event_ids` is the set of event IDs the viewer has +/// published from this app (kind:1111 comments, kind:30078 markets, +/// etc., all carrying the NIP-89 client tag). Notifications targeting +/// any other event are dropped — keeps the bell from filling up with +/// reactions/zaps the user got on their non-Deadcat Nostr activity. +/// Pass an empty set to disable filtering (only useful while still +/// building the tracker on cold start). +pub fn parse_notification_event( + event: &Event, + my_pubkey_hex: &str, + own_deadcat_event_ids: &std::collections::HashSet, +) -> Option { // Self-authored events never generate notifications — you don't // need to be told about your own reply / reaction / zap receipt. if event.pubkey.to_hex().eq_ignore_ascii_case(my_pubkey_hex) { return None; } + /// Does this notification target an event the viewer authored + /// through Deadcat? Empty `own_set` opts out of filtering — used + /// while the tracker is still hydrating on cold start so + /// notifications aren't silently dropped. + fn targets_deadcat_event( + target: Option<&str>, + own_set: &std::collections::HashSet, + ) -> bool { + if own_set.is_empty() { + return true; + } + match target { + Some(id) => own_set.contains(id), + None => false, + } + } + let market = tag_value(event, "A") .and_then(parse_market_coordinate) .map(|(id, creator)| (Some(id), Some(creator))); @@ -333,6 +363,13 @@ pub fn parse_notification_event(event: &Event, my_pubkey_hex: &str) -> Option Option { // NIP-25: target event in lowercase `e`, emoji in content. let comment_id = tag_value(event, "e").map(String::from)?; + // Reactions on non-Deadcat content are out of scope for + // the bell; the user can see those in their Nostr client + // of choice. + if !targets_deadcat_event(Some(&comment_id), own_deadcat_event_ids) { + return None; + } let emoji = event.content.trim(); if emoji.is_empty() { return None; @@ -378,6 +421,15 @@ pub fn parse_notification_event(event: &Event, my_pubkey_hex: &str) -> Option Date: Sat, 25 Apr 2026 13:58:45 -0400 Subject: [PATCH 3/7] fix(notifications): strict deadcat-only filter + persist + prune legacy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first cut of the deadcat-scoped notifications filter shipped with an empty-set bypass: if the in-memory own-events tracker was empty (cold-start window before historical replay completed, or a brand-new user with an existing Nostr identity), every event-targeted notification passed through unfiltered. Users who brought an existing pubkey were still seeing their full Nostr notification history. Three changes close the leak: 1. **Strict filter.** \`targets_deadcat_event\` now drops every event-targeted notification when the own-events set doesn't contain the target id, regardless of whether the set is empty. Brand-new users see an empty bell until they post from Deadcat — correct behaviour. 2. **Profile-level zaps drop too.** Kind:9735 receipts with no e-tag target a pubkey, not an event. They're indistinguishable from non-Deadcat zaps so they're dropped under the strict filter. 3. **Persistent own-events store.** New \`OwnEventsStore\` saves the set to \`/own_deadcat_events.json\` so a cold start doesn't reset to empty before historical replay. Loaded as the subscription's hydrated baseline; appended on every new own-event. 4. **One-shot legacy prune.** A scheduled task (10s after subscription start) sweeps the persisted notifications file against the fully replayed own-events set, dropping anything that doesn't target a Deadcat-authored event. Cleans up records that leaked through during the bypass-era without requiring a manual "Mark all read" from the user. Delayed rather than per-event so historical replay has time to populate the full set before pruning runs. --- src-tauri/src/commands.rs | 64 +++++++++++++++-- src-tauri/src/lib.rs | 8 +++ src-tauri/src/notifications.rs | 126 +++++++++++++++++++++++++++++---- 3 files changed, 179 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 880af1f8..7de82a33 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4498,6 +4498,7 @@ async fn run_notifications_subscription( let my_pubkey_hex = my_pubkey.to_hex(); let store = get_or_init_notification_store(&app).await?; + let own_events_store = get_or_init_own_events_store(&app).await?; let resume_since = store.resume_since(); let client = node.discovery().client().clone(); @@ -4531,11 +4532,33 @@ async fn run_notifications_subscription( &my_pubkey_hex[..8] ); - // In-memory set of event IDs the viewer has authored through - // Deadcat. Hydrated as own-event-filter results stream in; used - // to filter notifications targeting non-Deadcat content. - let mut own_deadcat_event_ids: std::collections::HashSet = - std::collections::HashSet::new(); + // Hydrate from persisted set so the cold-start window doesn't + // let the entire pre-existing notification history through + // before the live subscription has finished replaying our own + // events. New ids appended by the subscription loop below. + let mut own_deadcat_event_ids: std::collections::HashSet = own_events_store.snapshot(); + + // One-shot prune of the persisted notifications store, scheduled + // once historical replay should be complete. Closes the migration + // gap where notifications inserted during a prior session's + // empty-set bypass would otherwise stay visible until manually + // cleared. Running AFTER a delay (rather than per-event) avoids + // over-pruning before all our own deadcat-authored events have + // replayed. + { + let prune_store = store.clone(); + let prune_own = own_events_store.clone(); + let prune_app = app.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(10)).await; + let set = prune_own.snapshot(); + if prune_store.prune_to_deadcat_targets(&set) { + prune_store.flush(); + let _ = prune_app.emit("notifications_updated", ()); + log::info!("notifications: pruned legacy entries to Deadcat-authored targets"); + } + }); + } // The relay pool pushes every matched event through the shared // `notifications()` broadcast channel. Discovery events also @@ -4551,8 +4574,13 @@ async fn run_notifications_subscription( // notifications targeting non-Deadcat content. The relay // returns historical own-events on subscribe, so the set // populates quickly without requiring a separate fetch. + // Persist on every new id so the next launch starts with a + // hydrated set. if event.pubkey == my_pubkey && event_has_deadcat_client_tag(&event) { - own_deadcat_event_ids.insert(event.id.to_hex()); + let id = event.id.to_hex(); + if own_deadcat_event_ids.insert(id.clone()) && own_events_store.insert(id) { + own_events_store.flush(); + } continue; } @@ -4605,6 +4633,30 @@ async fn get_or_init_notification_store( Ok(store) } +/// Persistent set of own-deadcat-authored event IDs. Lazy-init on +/// first use, scoped per network so testnet and mainnet can't +/// cross-contaminate. Same pattern as `get_or_init_notification_store`. +async fn get_or_init_own_events_store( + app: &tauri::AppHandle, +) -> Result, String> { + let state = app.state::(); + let mut guard = state.own_events.lock().await; + if let Some(store) = guard.as_ref() { + return Ok(store.clone()); + } + let app_data_dir = app + .path() + .app_data_dir() + .map_err(|e| format!("failed to get app data dir: {e}"))?; + let network_tag = current_network_tag(app)?; + let store = std::sync::Arc::new(crate::notifications::OwnEventsStore::load( + &app_data_dir, + &network_tag, + )); + *guard = Some(store.clone()); + Ok(store) +} + /// Most-recent-first slice of the stored notifications, capped at /// `limit`. The frontend's bell popover typically asks for 30 at a /// time; anything beyond the retained 500 is already off the tail. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f54e335a..001dc8bd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -89,12 +89,20 @@ pub struct NostrAppState { /// task that appends to it. pub struct NotificationsState { pub store: tokio::sync::Mutex>>, + /// Persistent set of event IDs the viewer has authored through + /// Deadcat. Populated by the subscription task as own-events + /// arrive; consumed by the same task to filter inbound + /// notifications. Persisted so cold starts don't reset to empty + /// (which would let the entire pre-existing notification history + /// through during the brief hydration window). + pub own_events: tokio::sync::Mutex>>, } impl Default for NotificationsState { fn default() -> Self { Self { store: tokio::sync::Mutex::new(None), + own_events: tokio::sync::Mutex::new(None), } } } diff --git a/src-tauri/src/notifications.rs b/src-tauri/src/notifications.rs index 359d5009..a4476ea3 100644 --- a/src-tauri/src/notifications.rs +++ b/src-tauri/src/notifications.rs @@ -26,6 +26,80 @@ use serde::{Deserialize, Serialize}; /// across. const NOTIFICATIONS_FILE: &str = "notifications.json"; +/// Sibling file holding the set of event ids the viewer has +/// authored through Deadcat. Populated as own-events stream in; +/// persisted across restarts so the cold-start window doesn't drop +/// notifications targeting events whose own-record hasn't replayed +/// yet. +const OWN_EVENTS_FILE: &str = "own_deadcat_events.json"; + +/// Disk-backed set of own deadcat-authored event IDs. Sibling to +/// `NotificationStore` — same loading conventions, persisted as a +/// JSON array. The bell's notification filter consults this set to +/// decide whether each inbound reaction / zap / reply targets +/// content the user produced through Deadcat. +pub struct OwnEventsStore { + path: PathBuf, + entries: Mutex>, +} + +impl OwnEventsStore { + pub fn load(app_data_dir: &Path, network: &str) -> Self { + let path = app_data_dir.join(network).join(OWN_EVENTS_FILE); + let entries: std::collections::HashSet = match fs::read_to_string(&path) { + Ok(raw) => serde_json::from_str::>(&raw) + .unwrap_or_default() + .into_iter() + .collect(), + Err(_) => std::collections::HashSet::new(), + }; + Self { + path, + entries: Mutex::new(entries), + } + } + + /// Snapshot the current set. Cheap clone of a `HashSet`; + /// callers feed this into `parse_notification_event` per inbound + /// notification candidate. + pub fn snapshot(&self) -> std::collections::HashSet { + self.entries.lock().map(|g| g.clone()).unwrap_or_default() + } + + /// Insert an event id. Returns true if newly added so the caller + /// can decide whether to flush. + pub fn insert(&self, event_id: String) -> bool { + match self.entries.lock() { + Ok(mut g) => g.insert(event_id), + Err(_) => false, + } + } + + /// Persist the in-memory set to disk. Best-effort — a transient + /// IO failure just means the next cold start may see a slightly + /// stale set, which the live subscription will rebuild. + pub fn flush(&self) { + let entries: Vec = match self.entries.lock() { + Ok(g) => g.iter().cloned().collect(), + Err(_) => return, + }; + if let Some(parent) = self.path.parent() { + if let Err(e) = fs::create_dir_all(parent) { + log::warn!("own_events: failed to mkdir {:?}: {e}", parent); + return; + } + } + match serde_json::to_string(&entries) { + Ok(raw) => { + if let Err(e) = fs::write(&self.path, raw) { + log::warn!("own_events: failed to write {:?}: {e}", self.path); + } + } + Err(e) => log::warn!("own_events: failed to serialize: {e}"), + } + } +} + /// Hard cap on retained notifications. Beyond this the oldest are /// pruned. 500 covers weeks of normal use; preventing an unbounded /// file is more important than perfect fidelity. @@ -143,6 +217,31 @@ impl NotificationStore { entries.iter().filter(|e| !e.read).count() as u32 } + /// Drop every persisted notification that doesn't target an + /// event in the supplied own-deadcat-events set. Returns `true` + /// when any record was removed so the caller can decide to + /// flush + emit an event. Called whenever the own-events set + /// grows, so a notification's target id that arrives late on + /// the replay can still rescue an existing record from the + /// pruning sweep — the prune only drops records the set already + /// knows aren't Deadcat-authored. + /// + /// Profile-level zap entries (no `comment_id`) are dropped too: + /// they're pubkey-scoped and indistinguishable from non-Deadcat + /// activity, matching the live filter in `parse_notification_event`. + pub fn prune_to_deadcat_targets(&self, own_set: &std::collections::HashSet) -> bool { + let mut entries = match self.entries.lock() { + Ok(g) => g, + Err(_) => return false, + }; + let before = entries.len(); + entries.retain(|record| match &record.comment_id { + Some(id) => own_set.contains(id), + None => false, + }); + before != entries.len() + } + /// Flip a single entry to read. Returns `true` when something /// changed so the caller knows whether to flush. pub fn mark_read(&self, event_id: &str) -> bool { @@ -330,16 +429,17 @@ pub fn parse_notification_event( } /// Does this notification target an event the viewer authored - /// through Deadcat? Empty `own_set` opts out of filtering — used - /// while the tracker is still hydrating on cold start so - /// notifications aren't silently dropped. + /// through Deadcat? Strict — an empty tracker means "never + /// published anything from Deadcat," which is the correct + /// behaviour for a user who brought an existing Nostr identity + /// over and just wants the bell to be empty until they + /// participate. The earlier "empty set disables filtering" + /// version let the entire pre-existing notification history + /// through on cold start before the own-events stream hydrated. fn targets_deadcat_event( target: Option<&str>, own_set: &std::collections::HashSet, ) -> bool { - if own_set.is_empty() { - return true; - } match target { Some(id) => own_set.contains(id), None => false, @@ -421,13 +521,13 @@ pub fn parse_notification_event( // request's `pubkey` field, not the receipt's author // (which is the recipient's LNURL provider). let comment_id = tag_value(event, "e").map(String::from); - // Profile-level zaps (no `e` tag) come through whatever - // their target — they target the user directly, not a - // specific comment, so it's fine to keep them. Event- - // level zaps must hit one of our Deadcat events. - if comment_id.is_some() - && !targets_deadcat_event(comment_id.as_deref(), own_deadcat_event_ids) - { + // Both event-level zaps (e-tag present) and profile- + // level zaps (no e-tag) must target a Deadcat-authored + // event to count. Profile zaps are pubkey-scoped and + // could be initiated from any client — without an e-tag + // we can't tell whether the user wanted to see them in + // the Deadcat bell. Strict scoping drops them. + if !targets_deadcat_event(comment_id.as_deref(), own_deadcat_event_ids) { return None; } let amount_msats = zap_receipt_amount_msats(event); From 0fa85676c9f627d0544fd3e9666256bc165ec6c1 Mon Sep 17 00:00:00 2001 From: The Daniel Date: Sat, 25 Apr 2026 14:02:00 -0400 Subject: [PATCH 4/7] fix(ui): two-edge category bar fade + tighter notifications empty state Two narrow-viewport polish fixes that surfaced together: - **Notifications empty state** was bleeding into the popover edges. Capped at 280px, added padding, leaning leading-relaxed so the line wraps inside its column instead of jamming against the borders. - **Category bar** had a one-sided fade that only hinted at overflow on the right; users scrolling left would still see abrupt clipping ("ding" on the screenshot, where Trending was cut at the start). Two changes: - Added a mirror left fade so both edges show the gradient when there's content past them. Both fades widened from 32px to 48px so the cue is harder to miss. - Each fade is gated by a data attribute (`data-overflow-left` / `data-overflow-right`) computed from `scrollLeft` / `scrollWidth` / `clientWidth`. ResizeObserver + scroll listener keep them in sync; transitions on opacity smooth the toggle so the gradients don't snap on/off. - Active pill auto-scrolls into view on activeCategory change, so a sign-in or deep-link that activates a clipped category no longer leaves the new state hidden behind the fade. Hamburger / mobile-nav rework still tracked for the dedicated mobile responsive pass. --- src/components/layout/NotificationBell.tsx | 2 +- src/components/layout/TopShell.tsx | 70 +++++++++++++++++++--- src/style.css | 38 +++++++++--- 3 files changed, 94 insertions(+), 16 deletions(-) diff --git a/src/components/layout/NotificationBell.tsx b/src/components/layout/NotificationBell.tsx index b6e27752..1686dd2b 100644 --- a/src/components/layout/NotificationBell.tsx +++ b/src/components/layout/NotificationBell.tsx @@ -119,7 +119,7 @@ function NotificationPopover() { {isLoading ? (

Loading…

) : items.length === 0 ? ( -

+

Nothing new yet. Replies, reactions, and zaps on your comments show up here.

diff --git a/src/components/layout/TopShell.tsx b/src/components/layout/TopShell.tsx index 0f9f3f3a..276f5bed 100644 --- a/src/components/layout/TopShell.tsx +++ b/src/components/layout/TopShell.tsx @@ -1,6 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { getCurrentWindow } from "@tauri-apps/api/window"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { categories } from "../../constants"; import { useEscapeKey } from "../../hooks/useEscapeKey"; import { setWalletNeedsBackup } from "../../hooks/useWalletNeedsBackup"; @@ -578,16 +578,71 @@ function CategoryBar() { (category !== "My Markets" || (nostrPubkey && marketMakerMode)), ); + // Track which edges of the scroll row have hidden content so the + // fade gradients only render where they're actually informative + // (no fake "more content this way" hint when the row already shows + // everything or is scrolled flush against an end). Recomputed on + // scroll, viewport resize, and category-list changes — the last + // because filteredCategories can grow or shrink with sign-in / + // marketMakerMode toggles. + const scrollRef = useRef(null); + const [overflowLeft, setOverflowLeft] = useState(false); + const [overflowRight, setOverflowRight] = useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: filteredCategories.length re-runs the measure when the list changes + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const measure = () => { + // 1px slack for sub-pixel rounding so the right fade doesn't + // flicker on / off as the user scrolls to the very end. + setOverflowLeft(el.scrollLeft > 0); + setOverflowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 1); + }; + measure(); + el.addEventListener("scroll", measure, { passive: true }); + const observer = new ResizeObserver(measure); + observer.observe(el); + return () => { + el.removeEventListener("scroll", measure); + observer.disconnect(); + }; + }, [filteredCategories.length]); + + // Auto-scroll the active pill into view when the category changes + // — important when the active category sits in the clipped portion + // of the row (e.g. user clicks a deep link or the category list + // grows after sign-in). The body reads the active pill via DOM + // selector rather than from the React tree, so biome's exhaustive- + // deps lint flags `activeCategory` as unused; it's actually the + // re-run trigger. + // biome-ignore lint/correctness/useExhaustiveDependencies: activeCategory triggers the scroll-into-view re-measure + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const active = el.querySelector('[data-active="true"]'); + active?.scrollIntoView({ block: "nearest", inline: "nearest" }); + }, [activeCategory]); + return (
{/* Outer wrap establishes a positioning context for the - right-edge fade pseudo-element. The inner row scrolls - horizontally; the fade is a hint that there's more - content beyond the cut-off when the viewport is too - narrow to show every category at once. */} -
-
+ edge-fade gradients. Each fade is a separate pseudo on + the wrapper and is conditionally rendered via a data + attribute — it only appears when the row actually has + more content past that edge, so users don't see a + phantom "more this way" hint when they're already at an + extreme. */} +
+
{filteredCategories.map((category) => { const active = activeCategory === category; const icon = categoryIcon(category); @@ -595,6 +650,7 @@ function CategoryBar() {