Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/crates/deadcat-sdk/src/discovery/attestation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
12 changes: 4 additions & 8 deletions src-tauri/crates/deadcat-sdk/src/discovery/comments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/deadcat-sdk/src/discovery/market.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
16 changes: 16 additions & 0 deletions src-tauri/crates/deadcat-sdk/src/discovery/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -239,6 +253,7 @@ fn build_order_tags(
vec!["true".to_string()],
));
}
tags.push(client_tag());
tags
}

Expand Down Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/deadcat-sdk/src/discovery/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/crates/deadcat-sdk/src/discovery/reactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions src-tauri/crates/deadcat-sdk/src/discovery/social.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,15 @@ pub fn build_follow_list_event(
follows: &[String],
legacy_content: &str,
) -> Result<UnsignedEvent, String> {
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
// event, losing the entire follow list.
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))
Expand All @@ -219,7 +220,8 @@ pub fn build_mute_list_event(
public: &[MuteEntry],
encrypted_content: &str,
) -> UnsignedEvent {
let tags: Vec<Tag> = public.iter().map(|entry| entry.tag()).collect();
let mut tags: Vec<Tag> = public.iter().map(|entry| entry.tag()).collect();
tags.push(super::client_tag());
EventBuilder::new(MUTE_LIST_KIND, encrypted_content)
.tags(tags)
.build(author)
Expand Down
1 change: 1 addition & 0 deletions src-tauri/crates/deadcat-sdk/src/discovery/zaps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/crates/deadcat-sdk/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ pub use discovery::{
// Types
AttestationContent,
AttestationResult,
CLIENT_NAME,
COMMENT_KIND,
CONTRACT_TAG,
CommentParent,
Expand Down Expand Up @@ -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,
Expand Down
107 changes: 100 additions & 7 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4498,39 +4498,97 @@ 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),
nostr_sdk::Kind::Custom(9735),
])
.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!(
"notifications subscription started (since={resume_since}, me={})",
&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<String> = 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) {
Expand All @@ -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
Expand All @@ -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<std::sync::Arc<crate::notifications::OwnEventsStore>, String> {
let state = app.state::<NotificationsState>();
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.
Expand Down
11 changes: 9 additions & 2 deletions src-tauri/src/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -258,6 +259,7 @@ pub fn build_backup_empty_replacement(keys: &Keys) -> Result<Event, String> {
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, "")
Expand All @@ -274,7 +276,10 @@ pub fn build_backup_deletion_event(keys: &Keys) -> Result<Event, String> {
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)
Expand All @@ -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<Event, String> {
let tags: Vec<Tag> = relays
let mut tags: Vec<Tag> = 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)
Expand Down Expand Up @@ -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}"))?;
Expand Down
8 changes: 8 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,12 +89,20 @@ pub struct NostrAppState {
/// task that appends to it.
pub struct NotificationsState {
pub store: tokio::sync::Mutex<Option<std::sync::Arc<notifications::NotificationStore>>>,
/// 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<Option<std::sync::Arc<notifications::OwnEventsStore>>>,
}

impl Default for NotificationsState {
fn default() -> Self {
Self {
store: tokio::sync::Mutex::new(None),
own_events: tokio::sync::Mutex::new(None),
}
}
}
Expand Down
Loading
Loading