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..7de82a33 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -4498,11 +4498,22 @@ 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(); - 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 +4521,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 +4532,63 @@ async fn run_notifications_subscription( &my_pubkey_hex[..8] ); + // 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. 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. + // 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) { + let id = event.id.to_hex(); + if own_deadcat_event_ids.insert(id.clone()) && own_events_store.insert(id) { + own_events_store.flush(); + } + 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 +4600,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 @@ -4564,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/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/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 0308cb98..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 { @@ -307,15 +406,46 @@ 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? 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 { + 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 +463,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 +521,15 @@ pub fn parse_notification_event(event: &Event, my_pubkey_hex: &str) -> Option + // Mutually exclusive with the user menu — opening either + // closes the other to avoid cross-popover overlap. useStore.setState((s) => ({ notificationsOpen: !s.notificationsOpen, + userMenuOpen: false, })) } title="Notifications" @@ -119,7 +122,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 2f25647a..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,36 +578,99 @@ 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 (
-
- {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 + 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); + return ( + + ); + })} + {/* Help button — hidden until content is ready */} +
diff --git a/src/components/layout/UserMenu.tsx b/src/components/layout/UserMenu.tsx index 91f85fc7..5d5e73ab 100644 --- a/src/components/layout/UserMenu.tsx +++ b/src/components/layout/UserMenu.tsx @@ -19,7 +19,13 @@ export function UserMenu() { }, [userMenuOpen]); const toggleMenu = useCallback(() => { - useStore.setState((s) => ({ userMenuOpen: !s.userMenuOpen })); + // Mutually exclusive with the notifications popover — opening + // either closes the other so we never get the cross-popover + // overlap (avatar dropdown lapping the bell dropdown). + useStore.setState((s) => ({ + userMenuOpen: !s.userMenuOpen, + notificationsOpen: false, + })); }, []); const copyNpub = useCallback(async () => { diff --git a/src/style.css b/src/style.css index 4b64c9e9..eaceb664 100644 --- a/src/style.css +++ b/src/style.css @@ -576,6 +576,49 @@ button:focus-visible, animation: deadcat-notification-pulse 2s ease-out both; } +/* Edge-fade indicators for the horizontally-scrolling category row. + Two pseudo-elements (`::before` for the left, `::after` for the + right) are toggled by `data-overflow-left` / `data-overflow-right` + on the wrapper so each only paints when there's actually more + content past that edge. Without the data-driven hide, the user + sees a phantom "more this way" hint when they're already + scrolled all the way over. + The gradient width is intentionally larger than the original 32px + pass — the fade alone reads as "the page just stops" at small + sizes, while a wider fade gradient suggests motion. Combined with + the active-pill auto-scroll-into-view in CategoryBar.tsx, the + user gets a clear "there's more, you can scroll" cue without + needing arrow buttons or a hamburger. */ +.category-bar-scroll::before, +.category-bar-scroll::after { + content: ""; + position: absolute; + top: 0; + bottom: 0; + /* 24px is enough to read as "fade" without bleeding into the + content of an adjacent pill. Earlier 48px overlapped active + pills near either edge (Trending / New) far enough that they + looked dimmed even when fully visible. */ + width: 24px; + pointer-events: none; + opacity: 0; + transition: opacity 150ms ease; +} +.category-bar-scroll::before { + left: 0; + background: linear-gradient(to left, rgba(2, 6, 23, 0), #020617); +} +.category-bar-scroll::after { + right: 0; + background: linear-gradient(to right, rgba(2, 6, 23, 0), #020617); +} +.category-bar-scroll[data-overflow-left="true"]::before { + opacity: 1; +} +.category-bar-scroll[data-overflow-right="true"]::after { + opacity: 1; +} + /* 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