Skip to main content

vector_core/
emoji_packs.rs

1//! NIP-30 / NIP-51 custom emoji + pack support.
2//!
3//! Phase 1 (read path):
4//! - Parse kind 30030 emoji sets and kind 10030 user emoji lists.
5//! - Fetch a user's subscribed packs from relays, persist them locally.
6//! - Expose a flat `EmojiPack` API to the frontend for picker rendering.
7//!
8//! Spec: <https://nips.nostr.com/30>, <https://nips.nostr.com/51>.
9//!
10//! Metadata interop: NIP-51 standardises `title` / `image` / `description`
11//! tags on kind 30030. Ditto and Nostria emit non-standard `name` /
12//! `picture` / `about` instead. Vector reads both with spec preference
13//! and (eventually) dual-writes both on publish.
14
15use std::collections::HashMap;
16
17use nostr_sdk::prelude::*;
18use serde::{Deserialize, Serialize};
19
20use crate::state::nostr_client;
21
22/// NIP-51 kind for a user's "Emojis" list (replaceable, per-user).
23const KIND_EMOJI_LIST: u16 = 10030;
24
25/// NIP-51 kind for an "Emoji set" (parameterised replaceable).
26pub const KIND_EMOJI_SET: u16 = 30030;
27
28/// Wire-form prefix for kind 30030 `a` tags / DB primary keys.
29/// `format!("{}:…", KIND_EMOJI_SET)` is the obvious construction but
30/// `format!` is heavy (Display dispatch, intermediate capacity probing);
31/// for an addr that's read on every pack save / load / subscribe, hold
32/// it as a literal and assert at compile time that the kind matches.
33const KIND_EMOJI_SET_ADDR_PREFIX: &str = "30030:";
34const _: () = assert!(
35    KIND_EMOJI_SET == 30030,
36    "KIND_EMOJI_SET_ADDR_PREFIX literal must match KIND_EMOJI_SET"
37);
38
39/// Build a canonical `kind:pubkey:identifier` addr string with a single
40/// allocation sized exactly to the result. ~3× faster than the
41/// equivalent `format!` in microbenchmarks and has no fmt machinery on
42/// the hot path (load, save, subscribe all hit this).
43fn build_pack_addr(pubkey: &str, identifier: &str) -> String {
44    let mut s = String::with_capacity(
45        KIND_EMOJI_SET_ADDR_PREFIX.len() + pubkey.len() + 1 + identifier.len(),
46    );
47    s.push_str(KIND_EMOJI_SET_ADDR_PREFIX);
48    s.push_str(pubkey);
49    s.push(':');
50    s.push_str(identifier);
51    s
52}
53
54/// Network fetch budget for resolving the user's pack list + each pack.
55/// 8s was too tight in practice — a single slow relay handshake would
56/// flash "Pack Unavailable" at the user when the event was still on its
57/// way. 20s comfortably covers a cold Tor circuit / sleepy relay
58/// without making a genuinely-missing pack feel sluggish.
59const FETCH_TIMEOUT_SECS: u64 = 20;
60
61/// Base in-app cap on packs a user can equip (subscribe + own). This gates
62/// only the in-app add action; packs subscribed via other clients always load
63/// in full and are never sliced. The Vector badge raises it (see
64/// `effective_max_equipped_packs`).
65pub const MAX_EQUIPPED_PACKS: usize = 3;
66
67/// Badge-holder cap on equipped packs. Effectively unlimited for normal use.
68pub const MAX_EQUIPPED_PACKS_BADGE: usize = 100;
69
70/// Maximum emojis per own pack on publish. Shared packs received from
71/// the network may exceed this — the frontend truncates them at display
72/// time. This cap only enforces what *we* author. The Vector badge raises it
73/// (see `effective_max_emojis_per_pack`).
74pub const MAX_EMOJIS_PER_PACK: usize = 30;
75
76/// Badge-holder cap on emojis per own pack.
77pub const MAX_EMOJIS_PER_PACK_BADGE: usize = 100;
78
79/// In-app equipped-pack cap for the current account, raised when the Vector
80/// badge is held. Gate the in-app subscribe/create action on this — never the
81/// load/display path.
82pub fn effective_max_equipped_packs() -> usize {
83    if crate::badges::has_vector_badge() {
84        MAX_EQUIPPED_PACKS_BADGE
85    } else {
86        MAX_EQUIPPED_PACKS
87    }
88}
89
90/// Per-pack emoji authoring cap for the current account, raised when the
91/// Vector badge is held.
92pub fn effective_max_emojis_per_pack() -> usize {
93    if crate::badges::has_vector_badge() {
94        MAX_EMOJIS_PER_PACK_BADGE
95    } else {
96        MAX_EMOJIS_PER_PACK
97    }
98}
99
100// ============================================================================
101// Types
102// ============================================================================
103
104#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
105pub struct PackEmoji {
106    pub shortcode: String,
107    pub url: String,
108    pub sha256: Option<String>,
109}
110
111#[derive(Serialize, Deserialize, Clone, Debug)]
112pub struct EmojiPack {
113    /// Canonical NIP-19 `naddr1...` for this pack (no relay hints).
114    /// Mirrors the `profile.id == npub` pattern — frontend / IPC code
115    /// only ever speaks bech32. Internal storage + kind 10030 `a` tags
116    /// still use the raw `kind:pubkey:identifier` coordinate (DB
117    /// columns named `addr`, helpers exposed via `parse_pack_address`
118    /// / `naddr_from_addr`).
119    pub id: String,
120    /// Author of the kind 30030 event (hex).
121    pub pubkey: String,
122    /// `d` tag identifier.
123    pub identifier: String,
124    /// NIP-51 `title` with Ditto `name` fallback.
125    pub title: String,
126    /// NIP-51 `image` with Ditto `picture` fallback. Empty if neither.
127    pub image_url: String,
128    /// NIP-51 `description` with Ditto `about` fallback.
129    pub description: String,
130    pub emojis: Vec<PackEmoji>,
131    /// Owned packs surface a different UI affordance (edit pencil).
132    pub is_own: bool,
133    /// Event `created_at` — fed back into the relay filter so re-fetches
134    /// don't process older events on top of a newer cached pack.
135    pub updated_at: u64,
136}
137
138impl EmojiPack {
139    /// Raw NIP-51 coordinate (`kind:pubkey:identifier`). Used for kind
140    /// 10030 `a` tags + DB keying. One pre-sized allocation, no fmt
141    /// machinery (see `build_pack_addr`).
142    pub fn addr(&self) -> String {
143        build_pack_addr(&self.pubkey, &self.identifier)
144    }
145}
146
147// ============================================================================
148// Parsing
149// ============================================================================
150
151/// Validate a NIP-30 shortcode: `[a-zA-Z0-9_-]+`, non-empty. Anything
152/// else would render brokenly against the `:[\w-]+:` regex our renderer
153/// uses, so we drop invalid items at parse time.
154fn is_valid_shortcode(s: &str) -> bool {
155    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
156}
157
158/// Active theme pack's `(shortcode, url)` pairs, registered from the frontend.
159/// The theme pack is shown in the picker without being a real subscription, so
160/// its shortcodes never land in `emoji_pack_items`; this lets the send resolver
161/// still attach NIP-30 tags for them. Replaced wholesale on theme change.
162static THEME_EMOJI_TAGS: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
163    std::sync::OnceLock::new();
164
165fn theme_emoji_tags() -> &'static std::sync::Mutex<Vec<(String, String)>> {
166    THEME_EMOJI_TAGS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
167}
168
169/// Register (or clear, with an empty vec) the active theme pack's emoji so the
170/// send resolver can tag them even though they aren't a DB subscription.
171pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
172    if let Ok(mut g) = theme_emoji_tags().lock() {
173        *g = tags;
174    }
175}
176
177/// Per-pack emoji cap mirrored from the frontend's `MAX_DISPLAY_EMOJIS_PER_PACK`
178/// (`applyBadgeLimits`). The send resolver caps each subscribed pack to this
179/// many items so its `~N` disambiguation of duplicate shortcodes matches exactly
180/// what the picker displayed — otherwise a large pack's hidden tail (the picker
181/// only shows the first N) would enter the candidate set and shift the indices.
182pub fn effective_display_cap() -> usize {
183    if crate::badges::has_vector_badge() { 100 } else { 30 }
184}
185
186/// Resolve a (possibly `~N`-suffixed) shortcode token against the candidate map.
187/// Plain `base` → the first candidate; `base~N` → the N-th (1-based) candidate
188/// in the same URL-sorted order the picker assigns. A `~N` whose base is unknown
189/// or whose index is out of range yields nothing (renders as literal text).
190fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
191    if let Some((base, suffix)) = token.rsplit_once('~') {
192        let n: usize = suffix.parse().ok()?;
193        if n == 0 { return None; }
194        return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
195    }
196    by_code.get(token).and_then(|urls| urls.first().cloned())
197}
198
199/// Scan `content` for `:shortcode:` patterns and resolve them against the user's
200/// currently-subscribed packs (plus the active theme pack). Returns deduped emoji
201/// tags in first-match order. Used by the send pipeline to attach NIP-30 emoji
202/// tags so recipients without the pack subscribed still render.
203///
204/// Duplicate shortcodes across packs are disambiguated `base~N` (1-based) by a
205/// lexicographic URL sort — the SAME ordering the frontend's `_assignEmojiDisambig`
206/// uses — so a `:love~2:` the picker inserted resolves here to the same image.
207pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
208    if content.is_empty() || !content.contains(':') {
209        return Vec::new();
210    }
211
212    // base shortcode -> ordered candidate URLs (one per distinct image).
213    let cap = effective_display_cap();
214    let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
215
216    // Subscribed packs. INNER JOIN matches `load_all_packs` — soft-removed own
217    // packs shouldn't leak their Blossom URLs through outbound tags when the
218    // user types a shortcode they thought was hidden.
219    if let Ok(conn) = crate::db::get_db_connection_guard_static() {
220        if let Ok(mut stmt) = conn.prepare(
221            "SELECT p.addr, i.shortcode, i.url
222             FROM emoji_pack_items i
223             INNER JOIN emoji_packs p ON p.addr = i.pack_addr
224             INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
225             ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
226        ) {
227            if let Ok(rows) = stmt.query_map([], |row| {
228                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
229            }) {
230                // Mirror the picker's per-pack display cap so hidden tail emojis
231                // don't enter (and skew) disambiguation.
232                let mut per_pack: HashMap<String, usize> = HashMap::new();
233                for (addr, code, url) in rows.flatten() {
234                    let n = per_pack.entry(addr).or_insert(0);
235                    if *n >= cap { continue; }
236                    *n += 1;
237                    by_code.entry(code).or_default().push(url);
238                }
239            }
240        }
241    }
242
243    // Active theme pack fills any gaps. It's shown in the picker without being
244    // a real subscription, so its shortcodes aren't in the DB — registered
245    // from the frontend via `set_theme_emoji_tags` (already capped there).
246    if let Ok(theme) = theme_emoji_tags().lock() {
247        for (code, url) in theme.iter() {
248            by_code.entry(code.clone()).or_default().push(url.clone());
249        }
250    }
251
252    if by_code.is_empty() {
253        return Vec::new();
254    }
255
256    // De-dup identical images + lexicographic sort so `~N` is stable and matches
257    // the frontend (two packs carrying the same URL collapse to one candidate).
258    for urls in by_code.values_mut() {
259        urls.sort();
260        urls.dedup();
261    }
262
263    let mut out: Vec<crate::types::EmojiTag> = Vec::new();
264    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
265    let bytes = content.as_bytes();
266    let mut i = 0;
267    while i < bytes.len() {
268        if bytes[i] == b':' {
269            let start = i + 1;
270            let mut j = start;
271            while j < bytes.len() {
272                let c = bytes[j];
273                // `~` is the reserved disambiguation separator (`:love~2:`).
274                let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
275                if !ok { break; }
276                j += 1;
277            }
278            if j > start && j < bytes.len() && bytes[j] == b':' {
279                if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
280                    if !seen.contains(token) {
281                        if let Some(url) = resolve_emoji_token(&by_code, token) {
282                            out.push(crate::types::EmojiTag {
283                                shortcode: token.to_string(),
284                                url,
285                            });
286                            seen.insert(token.to_string());
287                        }
288                    }
289                }
290                i = j + 1;
291                continue;
292            }
293        }
294        i += 1;
295    }
296    out
297}
298
299/// Fetch the first single-string tag whose key matches any of `keys`,
300/// in order. Used for the dual NIP-51 / Ditto metadata lookup.
301fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
302    for key in keys {
303        for tag in tags.iter() {
304            let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
305            if parts.len() >= 2 && parts[0] == *key {
306                return Some(parts[1].to_string());
307            }
308        }
309    }
310    None
311}
312
313/// Parse a kind 30030 event into an EmojiPack. Returns `None` if the
314/// event is missing a `d` tag or has zero valid emoji rows.
315pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
316    if event.kind.as_u16() != KIND_EMOJI_SET {
317        return None;
318    }
319
320    let identifier = first_tag(&event.tags, &["d"])?;
321    let pubkey = event.pubkey.to_hex();
322    let addr = build_pack_addr(&pubkey, &identifier);
323    let id = match naddr_from_addr(&addr) {
324        Ok(s) => s,
325        Err(e) => {
326            crate::log_warn!(
327                "[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
328                addr, e,
329            );
330            return None;
331        }
332    };
333
334    let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
335    let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
336    let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
337
338    let mut emojis = Vec::new();
339    let mut seen = std::collections::HashSet::new();
340    for tag in event.tags.iter() {
341        let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
342        if parts.len() >= 3 && parts[0] == "emoji" {
343            let shortcode = parts[1];
344            if !is_valid_shortcode(shortcode) { continue; }
345            // Trim URL whitespace: some packs in the wild carry a stray leading/trailing space
346            // (e.g. "…/x.gif "), which the WebView strips for a raw <img> but the cache fetch does
347            // NOT — leaving the emoji blank everywhere it's served from cache. Skip if empty after.
348            let url = parts[2].trim();
349            if url.is_empty() { continue; }
350            if !seen.insert(shortcode.to_string()) { continue; }
351            emojis.push(PackEmoji {
352                shortcode: shortcode.to_string(),
353                url: url.to_string(),
354                sha256: None,
355            });
356        }
357    }
358
359    if emojis.is_empty() {
360        return None;
361    }
362
363    let is_own = my_pubkey_hex.map_or(false, |me| me == pubkey);
364
365    Some(EmojiPack {
366        id,
367        pubkey,
368        identifier,
369        title,
370        image_url,
371        description,
372        emojis,
373        is_own,
374        updated_at: event.created_at.as_secs(),
375    })
376}
377
378/// Parsed NIP-19 / NIP-51 set address.
379#[derive(Debug, Clone, PartialEq)]
380pub struct PackAddress {
381    pub kind: u16,
382    pub pubkey: PublicKey,
383    pub identifier: String,
384}
385
386/// Parse a `kind:pubkey-hex:d-tag` address as found in kind 10030 `a` tags.
387/// Rejects anything that isn't kind 30030 — we don't want a malformed list
388/// pulling in random replaceable events.
389pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
390    let mut parts = addr.splitn(3, ':');
391    let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
392    let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
393    let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
394
395    let kind: u16 = kind_str.parse()
396        .map_err(|_| format!("invalid kind: {}", kind_str))?;
397    if kind != KIND_EMOJI_SET {
398        return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
399    }
400    let pubkey = PublicKey::from_hex(pubkey_str)
401        .map_err(|e| format!("invalid pubkey: {}", e))?;
402
403    Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
404}
405
406impl PackAddress {
407    /// Serialise back to the wire form used in kind 10030 `a` tags.
408    /// `parse_pack_address` is the only constructor and it rejects any
409    /// kind ≠ KIND_EMOJI_SET, so we can route through the optimised
410    /// `build_pack_addr` and skip `format!` entirely.
411    pub fn to_addr_string(&self) -> String {
412        debug_assert_eq!(self.kind, KIND_EMOJI_SET,
413            "PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
414        build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
415    }
416}
417
418/// Encode a pack `addr` (`kind:pubkey:identifier`) into a NIP-19
419/// `naddr1...` bech32 string. Used by the share-pack flow to put a
420/// portable reference on the user's clipboard.
421pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
422    let parsed = parse_pack_address(addr)?;
423    let coord = nostr_sdk::nips::nip01::Coordinate {
424        kind: Kind::Custom(parsed.kind),
425        public_key: parsed.pubkey,
426        identifier: parsed.identifier,
427    };
428    let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
429        coordinate: coord,
430        relays: Vec::new(),
431    };
432    nostr_sdk::nips::nip19::Nip19::Coordinate(n19)
433        .to_bech32()
434        .map_err(|e| format!("encode naddr: {}", e))
435}
436
437/// Decode a NIP-19 `naddr1...` into a `PackAddress`. Rejects coordinates
438/// that don't point at kind 30030 so a malformed paste can't pull in
439/// an unrelated replaceable event.
440pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
441    let trimmed = naddr.trim().trim_start_matches("nostr:");
442    let parsed = nostr_sdk::nips::nip19::Nip19::from_bech32(trimmed)
443        .map_err(|e| format!("invalid naddr: {}", e))?;
444    let coord = match parsed {
445        nostr_sdk::nips::nip19::Nip19::Coordinate(c) => c,
446        _ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
447    };
448    let kind = coord.kind.as_u16();
449    if kind != KIND_EMOJI_SET {
450        return Err(format!(
451            "expected kind {} (emoji set), got {}",
452            KIND_EMOJI_SET, kind,
453        ));
454    }
455    Ok(PackAddress {
456        kind,
457        pubkey: coord.public_key,
458        identifier: coord.identifier.clone(),
459    })
460}
461
462/// Parse a NIP-51 inner tag list (the JSON array of tag tuples that
463/// lives inside the NIP-44-encrypted `content` of an encrypted-items
464/// list). Pulls out `a` tags as pack addresses; malformed inner
465/// entries are dropped silently so one bad row doesn't nuke the list.
466fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
467    let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
468        Ok(v) => v,
469        Err(e) => {
470            crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
471            return Vec::new();
472        }
473    };
474    inner.into_iter()
475        .filter_map(|tup| {
476            if tup.len() >= 2 && tup[0] == "a" {
477                parse_pack_address(&tup[1]).ok()
478            } else {
479                None
480            }
481        })
482        .collect()
483}
484
485/// Decrypt + parse a kind 10030 event's encrypted subscription list.
486///
487/// Vector's emoji list is fully private by design — every `a` tag is
488/// carried inside the NIP-44-self-encrypted `content`, never in the
489/// public `tags` field. A list event with empty / undecryptable /
490/// malformed content is treated as "no subscriptions" rather than
491/// failing the whole refresh. Spec: NIP-51 "encrypted items" section.
492pub async fn decrypt_subscribed_addresses(
493    client: &Client,
494    my_pk: &PublicKey,
495    event: &Event,
496) -> Vec<PackAddress> {
497    if event.content.is_empty() {
498        return Vec::new();
499    }
500    let signer = match client.signer().await {
501        Ok(s) => s,
502        Err(e) => {
503            crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
504            return Vec::new();
505        }
506    };
507    let plaintext = match signer.nip44_decrypt(my_pk, &event.content).await {
508        Ok(p) => p,
509        Err(e) => {
510            crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
511            return Vec::new();
512        }
513    };
514    parse_inner_tag_list(&plaintext)
515}
516
517// ============================================================================
518// DB persistence
519// ============================================================================
520
521pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
522    let mut conn = crate::db::get_write_connection_guard_static()?;
523    let tx = conn.transaction()
524        .map_err(|e| format!("Failed to start tx: {}", e))?;
525
526    let addr = pack.addr();
527    tx.execute(
528        "INSERT OR REPLACE INTO emoji_packs
529            (addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
530         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
531        rusqlite::params![
532            addr, pack.pubkey, pack.identifier,
533            pack.title, pack.image_url, pack.description,
534            pack.is_own as i32, pack.updated_at as i64,
535        ],
536    ).map_err(|e| format!("Failed to upsert pack: {}", e))?;
537
538    // Replace the item set wholesale — kind 30030 is a replaceable event,
539    // older shortcodes that disappeared from the new version must not
540    // linger in our local mirror.
541    tx.execute(
542        "DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
543        rusqlite::params![addr],
544    ).map_err(|e| format!("Failed to clear pack items: {}", e))?;
545
546    for (pos, emoji) in pack.emojis.iter().enumerate() {
547        tx.execute(
548            "INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
549             VALUES (?1, ?2, ?3, ?4, ?5)",
550            rusqlite::params![
551                addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
552            ],
553        ).map_err(|e| format!("Failed to insert pack item: {}", e))?;
554    }
555
556    tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
557    Ok(())
558}
559
560pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
561    let mut conn = crate::db::get_write_connection_guard_static()?;
562    let tx = conn.transaction()
563        .map_err(|e| format!("Failed to start tx: {}", e))?;
564
565    tx.execute("DELETE FROM emoji_pack_subscriptions", [])
566        .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
567
568    let now = std::time::SystemTime::now()
569        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
570    for addr in addrs {
571        tx.execute(
572            "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at)
573             VALUES (?1, ?2)",
574            rusqlite::params![addr, now],
575        ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
576    }
577
578    tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
579    Ok(())
580}
581
582pub fn load_subscriptions() -> Result<Vec<String>, String> {
583    let conn = crate::db::get_db_connection_guard_static()?;
584    let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY subscribed_at ASC")
585        .map_err(|e| format!("prepare: {}", e))?;
586    let rows = stmt.query_map([], |row| row.get::<_, String>(0))
587        .map_err(|e| format!("query: {}", e))?;
588    let mut out = Vec::new();
589    for r in rows {
590        out.push(r.map_err(|e| format!("row: {}", e))?);
591    }
592    Ok(out)
593}
594
595/// Load a single cached pack by its raw `kind:pubkey:identifier` addr,
596/// regardless of subscription status. Used by the theme-pack path: a theme
597/// pack is persisted via `save_pack` (so it loads instantly across sessions)
598/// but never gets a subscription row, so `load_all_packs` rightly hides it.
599pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
600    let conn = crate::db::get_db_connection_guard_static()?;
601
602    let mut pack = match conn.query_row(
603        "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at
604         FROM emoji_packs WHERE addr = ?1",
605        rusqlite::params![addr],
606        |row| {
607            Ok(EmojiPack {
608                id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
609                pubkey: row.get(0)?,
610                identifier: row.get(1)?,
611                title: row.get(2)?,
612                image_url: row.get(3)?,
613                description: row.get(4)?,
614                is_own: row.get::<_, i32>(5)? != 0,
615                updated_at: row.get::<_, i64>(6)? as u64,
616                emojis: Vec::new(),
617            })
618        },
619    ) {
620        Ok(p) => p,
621        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
622        Err(e) => return Err(format!("query cached pack: {}", e)),
623    };
624
625    let mut stmt = conn.prepare(
626        "SELECT shortcode, url, sha256 FROM emoji_pack_items
627         WHERE pack_addr = ?1 ORDER BY position ASC"
628    ).map_err(|e| format!("prepare items: {}", e))?;
629    let rows = stmt.query_map(rusqlite::params![addr], |row| {
630        Ok(PackEmoji {
631            shortcode: row.get(0)?,
632            url: row.get(1)?,
633            sha256: row.get(2)?,
634        })
635    }).map_err(|e| format!("query items: {}", e))?;
636    for r in rows {
637        pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
638    }
639
640    Ok(Some(pack))
641}
642
643/// Load every locally-cached pack the user is currently subscribed to
644/// (plus their own packs, which always count). Hydrated with items.
645/// Cached non-subscribed pack rows stay in the DB so historic reactions
646/// still resolve their image URLs — they're just hidden from the picker.
647pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
648    let conn = crate::db::get_db_connection_guard_static()?;
649
650    let mut packs: HashMap<String, EmojiPack> = HashMap::new();
651    let mut order: Vec<String> = Vec::new();
652
653    {
654        // INNER JOIN — only show subscribed packs. Own packs are
655        // auto-subscribed when published (see `publish_pack`), so this
656        // surfaces them by default; if the user explicitly unsubscribes
657        // their own pack via the right-click "Remove" path, it drops out
658        // of the picker but stays on Nostr and in `emoji_packs` so a
659        // later re-subscribe (paste naddr) restores it with `is_own` set.
660        let mut stmt = conn.prepare(
661            "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at
662             FROM emoji_packs p
663             INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
664             ORDER BY p.is_own DESC, p.updated_at DESC"
665        ).map_err(|e| format!("prepare packs: {}", e))?;
666
667        let rows = stmt.query_map([], |row| {
668            // Row col 0 is the raw addr (kind:pubkey:identifier). Encode
669            // to naddr here so the public `id` field is consistent with
670            // what `parse_pack_from_event` produces.
671            let raw_addr: String = row.get(0)?;
672            let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
673            Ok((raw_addr, EmojiPack {
674                id,
675                pubkey: row.get(1)?,
676                identifier: row.get(2)?,
677                title: row.get(3)?,
678                image_url: row.get(4)?,
679                description: row.get(5)?,
680                is_own: row.get::<_, i32>(6)? != 0,
681                updated_at: row.get::<_, i64>(7)? as u64,
682                emojis: Vec::new(),
683            }))
684        }).map_err(|e| format!("query packs: {}", e))?;
685
686        for r in rows {
687            let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
688            order.push(raw_addr.clone());
689            packs.insert(raw_addr, pack);
690        }
691    }
692
693    {
694        let mut stmt = conn.prepare(
695            "SELECT pack_addr, shortcode, url, sha256
696             FROM emoji_pack_items
697             ORDER BY pack_addr, position ASC"
698        ).map_err(|e| format!("prepare items: {}", e))?;
699
700        let rows = stmt.query_map([], |row| {
701            Ok((
702                row.get::<_, String>(0)?,
703                PackEmoji {
704                    shortcode: row.get(1)?,
705                    url: row.get(2)?,
706                    sha256: row.get(3)?,
707                },
708            ))
709        }).map_err(|e| format!("query items: {}", e))?;
710
711        for r in rows {
712            let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
713            if let Some(p) = packs.get_mut(&addr) {
714                p.emojis.push(emoji);
715            }
716        }
717    }
718
719    Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
720}
721
722// ============================================================================
723// Author outbox (NIP-65)
724// ============================================================================
725
726/// How long a cached author relay list stays valid before re-fetching.
727/// NIP-65 lists rarely change (relay-set edits are a deliberate user act);
728/// an hour amortises the overhead without serving routing that's egregiously
729/// stale. Mirrors the TTL the NIP-17 inbox cache uses.
730const NIP65_CACHE_TTL_SECS: u64 = 3600;
731/// Shorter TTL after an empty/failed lookup so a transient relay blip doesn't
732/// suppress outbox routing for a whole hour.
733const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
734const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
735
736#[derive(Clone)]
737struct CachedRelayList {
738    relays: Vec<RelayUrl>,
739    fetched_at: std::time::Instant,
740    /// Empty fetches use the shorter TTL so transient outages recover fast.
741    empty: bool,
742}
743
744static NIP65_CACHE: std::sync::OnceLock<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> =
745    std::sync::OnceLock::new();
746
747fn nip65_cache() -> &'static std::sync::RwLock<HashMap<PublicKey, CachedRelayList>> {
748    NIP65_CACHE.get_or_init(|| std::sync::RwLock::new(HashMap::new()))
749}
750
751/// Read fresh write relays for `pubkey` from the cache, or `None` if absent /
752/// expired. Honours the dual TTL (short for empty entries).
753fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
754    let cache = nip65_cache().read().ok()?;
755    let entry = cache.get(pubkey)?;
756    let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
757    if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
758        Some(entry.relays.clone())
759    } else {
760        None
761    }
762}
763
764/// Drop every cached relay list (account swap — entries hold contact-graph metadata
765/// from the prior session, mirroring the inbox-relay cache's swap hygiene).
766pub fn clear_nip65_cache() {
767    if let Ok(mut cache) = nip65_cache().write() {
768        cache.clear();
769    }
770}
771
772/// Store a freshly-resolved write-relay list for `pubkey` in the cache.
773fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>) {
774    if let Ok(mut cache) = nip65_cache().write() {
775        let empty = relays.is_empty();
776        cache.insert(pubkey, CachedRelayList {
777            relays,
778            fetched_at: std::time::Instant::now(),
779            empty,
780        });
781    }
782}
783
784/// Extract the write relays from a kind-10002 event. NIP-65: marker absent =
785/// read+write (both), "write" = author publishes here, "read"-only = consumes
786/// only (useless for finding their packs), so we keep both/write and drop read.
787fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
788    let mut relays: Vec<RelayUrl> = Vec::new();
789    for (url, marker) in nostr_sdk::nips::nip65::extract_relay_list(ev) {
790        match marker {
791            None | Some(nostr_sdk::nips::nip65::RelayMetadata::Write) => {
792                if !relays.contains(url) {
793                    relays.push(url.clone());
794                }
795            }
796            Some(nostr_sdk::nips::nip65::RelayMetadata::Read) => {}
797        }
798    }
799    relays
800}
801
802/// Resolve the author's NIP-65 (kind-10002) write relays — where they publish.
803/// Returns an empty Vec on absence or fetch error; callers must treat absence
804/// as "no extra hints," not a failure. Cached per-pubkey. Used by the
805/// single-pack path; the batched list path uses `prefetch_author_write_relays`.
806async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
807    if let Some(relays) = cached_write_relays(&pubkey) {
808        return relays;
809    }
810
811    let filter = Filter::new()
812        .author(pubkey)
813        .kind(Kind::RelayList)
814        .limit(1);
815    let events = match client
816        .fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
817        .await
818    {
819        Ok(evs) => evs,
820        Err(_) => {
821            cache_write_relays(pubkey, Vec::new());
822            return Vec::new();
823        }
824    };
825
826    let relays = events.into_iter()
827        .max_by_key(|e| e.created_at)
828        .map(|ev| extract_write_relays(&ev))
829        .unwrap_or_default();
830    cache_write_relays(pubkey, relays.clone());
831    relays
832}
833
834/// Warm the NIP-65 cache for many authors in ONE request. Used by the batched
835/// subscribed-list path so a boot with N federated packs pays a single
836/// kind-10002 fetch instead of N. Authors already cached-fresh are skipped;
837/// authors with no kind-10002 are cached empty (short TTL) so we don't refetch
838/// them every pass.
839async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
840    let uncached: Vec<PublicKey> = authors.iter()
841        .filter(|pk| cached_write_relays(pk).is_none())
842        .copied()
843        .collect();
844    if uncached.is_empty() {
845        return;
846    }
847
848    let filter = Filter::new()
849        .authors(uncached.iter().copied())
850        .kind(Kind::RelayList);
851    let events = match client
852        .fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
853        .await
854    {
855        Ok(evs) => evs,
856        // On error, leave the cache cold — the next pass retries rather than
857        // poisoning every author with an empty entry off one failed batch.
858        Err(_) => return,
859    };
860
861    // Keep the newest kind-10002 per author, then cache each.
862    let mut newest: HashMap<PublicKey, Event> = HashMap::new();
863    for ev in events {
864        match newest.get(&ev.pubkey) {
865            Some(existing) if existing.created_at >= ev.created_at => {}
866            _ => { newest.insert(ev.pubkey, ev); }
867        }
868    }
869    for pk in uncached {
870        let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
871        cache_write_relays(pk, relays);
872    }
873}
874
875// ============================================================================
876// Relay fetch
877// ============================================================================
878
879/// Resolve a single pack from relays by its parsed address. Returns
880/// `None` when no matching event is found within the timeout — callers
881/// distinguish "unknown" from "fetch error" by the caller's own error
882/// pathway (every relay call here that errors logs and proceeds).
883async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
884    let filter = Filter::new()
885        .author(addr.pubkey)
886        .kind(Kind::Custom(KIND_EMOJI_SET))
887        .identifier(&addr.identifier)
888        .limit(1);
889    let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
890    let me = crate::state::my_public_key().map(|pk| pk.to_hex());
891
892    // 1) Home relays first (the shared pool). Covers our own packs and any
893    //    pack that's on Vector's default relays — the common, fast case.
894    match client.fetch_events(filter.clone(), timeout).await {
895        Ok(events) => {
896            if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
897                if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
898                    return Some(pack);
899                }
900            }
901        }
902        Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
903    }
904
905    // 2) Outbox fallback (NIP-65): the pack lives wherever the creator
906    //    publishes, which may sit outside our relays. Fetch through an
907    //    ISOLATED throwaway client so these third-party relays never enter
908    //    the shared pool — the DM/community sync loops enumerate that pool and
909    //    would otherwise reconcile against every pack author's relays.
910    let outbox = fetch_author_write_relays(client, addr.pubkey).await;
911    if outbox.is_empty() {
912        return None;
913    }
914    fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
915}
916
917/// Fetch a kind-30030 pack through a dedicated, short-lived client connected
918/// only to the given relays. Built with Tor-aware options; fully torn down
919/// before returning so nothing leaks into the app's relay pool or sync loops.
920async fn fetch_pack_via_isolated_client(
921    relays: &[RelayUrl],
922    filter: Filter,
923    timeout: std::time::Duration,
924    my_pubkey_hex: Option<&str>,
925) -> Option<EmojiPack> {
926    let scratch = ClientBuilder::new()
927        .opts(crate::nostr_client_options())
928        .build();
929    for r in relays {
930        let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
931        let _ = scratch.pool().add_relay(r.as_str(), opts).await;
932    }
933    scratch.connect().await;
934
935    let result = scratch.fetch_events(filter, timeout).await;
936    // Tear the scratch client down regardless of outcome.
937    scratch.shutdown().await;
938
939    let events = match result {
940        Ok(events) => events,
941        Err(e) => {
942            crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
943            return None;
944        }
945    };
946    let event = events.into_iter().max_by_key(|e| e.created_at)?;
947    parse_pack_from_event(&event, my_pubkey_hex)
948}
949
950// ============================================================================
951// Batched relay fetch (subscribed-list path ONLY)
952// ============================================================================
953//
954// `fetch_pack_from_relays` (above) resolves ONE pack and is used by the
955// per-pack flows: in-chat preview cards and the pinned theme pack, which
956// arrive as independent render events and must stay independent.
957//
958// `fetch_packs_from_relays` (below) resolves MANY packs whose coordinates are
959// all known up front — i.e. the user's own subscribed list. It collapses what
960// used to be N requests into one batched home request plus, for any packs not
961// on our relays, one batched NIP-65 prefetch and one batched outbox request.
962// These two paths intentionally do NOT share fetch logic: the single path is
963// kept byte-stable so the preview/theme behaviour can't regress.
964
965/// Coordinate key for matching a kind-30030 event back to a requested pack:
966/// `pubkey_hex:identifier`. (Not the `30030:`-prefixed addr — just the parts a
967/// fetched event exposes via its author + `d` tag.)
968fn event_coord(ev: &Event) -> Option<String> {
969    let d = first_tag(&ev.tags, &["d"])?;
970    Some(format!("{}:{}", ev.pubkey.to_hex(), d))
971}
972fn addr_coord(addr: &PackAddress) -> String {
973    format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
974}
975
976/// One batched filter matches the cross-product of authors × identifiers, so it
977/// can return events we didn't ask for (author A's `d` that belongs to author
978/// B's pack). Match strictly by exact coordinate and keep the newest event per
979/// coordinate; strays are dropped.
980fn parse_packs_by_coord(
981    events: impl IntoIterator<Item = Event>,
982    wanted: &std::collections::HashSet<String>,
983    me: Option<&str>,
984) -> HashMap<String, EmojiPack> {
985    let mut newest: HashMap<String, Event> = HashMap::new();
986    for ev in events {
987        if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
988        let Some(coord) = event_coord(&ev) else { continue; };
989        if !wanted.contains(&coord) { continue; }
990        match newest.get(&coord) {
991            Some(existing) if existing.created_at >= ev.created_at => {}
992            _ => { newest.insert(coord, ev); }
993        }
994    }
995    newest.into_iter()
996        .filter_map(|(coord, ev)| parse_pack_from_event(&ev, me).map(|p| (coord, p)))
997        .collect()
998}
999
1000/// Resolve MANY packs in a batch. Home relays in one request; any unresolved
1001/// packs then get one batched NIP-65 prefetch + one batched outbox request via
1002/// an isolated client. Returns the packs that resolved (in `addrs` order);
1003/// callers keep cached copies for the rest.
1004async fn fetch_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> Vec<EmojiPack> {
1005    if addrs.is_empty() {
1006        return Vec::new();
1007    }
1008    let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1009    let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1010    let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1011
1012    // 1) One batched home request for every subscribed pack.
1013    let home_filter = Filter::new()
1014        .authors(addrs.iter().map(|a| a.pubkey))
1015        .kind(Kind::Custom(KIND_EMOJI_SET))
1016        .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1017    let mut resolved: HashMap<String, EmojiPack> = match client.fetch_events(home_filter, timeout).await {
1018        Ok(events) => parse_packs_by_coord(events, &wanted, me.as_deref()),
1019        Err(e) => {
1020            crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1021            HashMap::new()
1022        }
1023    };
1024
1025    // 2) Outbox fallback for the misses, all in one shot.
1026    let misses: Vec<&PackAddress> = addrs.iter()
1027        .filter(|a| !resolved.contains_key(&addr_coord(a)))
1028        .collect();
1029    if !misses.is_empty() {
1030        let miss_authors: Vec<PublicKey> = {
1031            let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1032            v.sort(); v.dedup();
1033            v
1034        };
1035        // Warm NIP-65 for all missed authors in one request, then union their
1036        // write relays into a single isolated client + one batched request.
1037        prefetch_author_write_relays(client, &miss_authors).await;
1038        let mut outbox: Vec<RelayUrl> = Vec::new();
1039        for pk in &miss_authors {
1040            for r in cached_write_relays(pk).unwrap_or_default() {
1041                if !outbox.contains(&r) { outbox.push(r); }
1042            }
1043        }
1044        if !outbox.is_empty() {
1045            let miss_filter = Filter::new()
1046                .authors(misses.iter().map(|a| a.pubkey))
1047                .kind(Kind::Custom(KIND_EMOJI_SET))
1048                .identifiers(misses.iter().map(|a| a.identifier.clone()));
1049            let wanted_misses: std::collections::HashSet<String> =
1050                misses.iter().map(|a| addr_coord(a)).collect();
1051            if let Some(packs) = fetch_packs_via_isolated_client(
1052                &outbox, miss_filter, timeout, &wanted_misses, me.as_deref(),
1053            ).await {
1054                resolved.extend(packs);
1055            }
1056        }
1057    }
1058
1059    // Return in the caller's requested order.
1060    addrs.iter()
1061        .filter_map(|a| resolved.remove(&addr_coord(a)))
1062        .collect()
1063}
1064
1065/// Batched sibling of `fetch_pack_via_isolated_client`: fetch many packs from a
1066/// throwaway client connected to the given relays, matched by coordinate.
1067async fn fetch_packs_via_isolated_client(
1068    relays: &[RelayUrl],
1069    filter: Filter,
1070    timeout: std::time::Duration,
1071    wanted: &std::collections::HashSet<String>,
1072    my_pubkey_hex: Option<&str>,
1073) -> Option<HashMap<String, EmojiPack>> {
1074    let scratch = ClientBuilder::new()
1075        .opts(crate::nostr_client_options())
1076        .build();
1077    for r in relays {
1078        let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
1079        let _ = scratch.pool().add_relay(r.as_str(), opts).await;
1080    }
1081    scratch.connect().await;
1082    let result = scratch.fetch_events(filter, timeout).await;
1083    scratch.shutdown().await;
1084
1085    match result {
1086        Ok(events) => Some(parse_packs_by_coord(events, wanted, my_pubkey_hex)),
1087        Err(e) => {
1088            crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1089            None
1090        }
1091    }
1092}
1093
1094/// Fetch the user's kind 10030 list, resolve every referenced pack, and
1095/// persist the result locally. Session-guarded against an account swap
1096/// landing the new account's pack list in account A's DB.
1097///
1098/// Non-destructive: a missing kind 10030 event or a transient per-pack
1099/// fetch failure must NOT nuke the user's local subscription list — that
1100/// would wipe their picker on every relay blip. Cached pack data is the
1101/// fallback whenever a fresh fetch fails.
1102pub async fn fetch_subscribed_packs(
1103    client: &Client,
1104    my_pubkey: PublicKey,
1105    session: crate::state::SessionGuard,
1106) -> Result<Vec<EmojiPack>, String> {
1107    let list_filter = Filter::new()
1108        .author(my_pubkey)
1109        .kind(Kind::Custom(KIND_EMOJI_LIST))
1110        .limit(1);
1111
1112    let list_events = client
1113        .fetch_events(list_filter, std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1114        .await
1115        .map_err(|e| format!("fetch kind 10030: {}", e))?;
1116
1117    if !session.is_valid() {
1118        return Ok(Vec::new());
1119    }
1120
1121    // Source-of-truth selection. If relays returned a kind 10030, trust
1122    // its `a` tags as the canonical subscription set — UNLESS it predates
1123    // our own last publish, which means our latest republish hasn't
1124    // propagated yet and the relay is still serving a stale list. Trusting
1125    // a stale list would clobber a just-added pack (last-write-wins by
1126    // created_at). If relays returned *nothing*, that's a transient sync
1127    // gap — fall back to the local mirror either way.
1128    let local_addrs = || -> Vec<PackAddress> {
1129        load_subscriptions()
1130            .unwrap_or_default()
1131            .into_iter()
1132            .filter_map(|s| parse_pack_address(&s).ok())
1133            .collect()
1134    };
1135    let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1136        .ok()
1137        .flatten()
1138        .and_then(|s| s.parse().ok())
1139        .unwrap_or(0);
1140
1141    let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1142    let addrs: Vec<PackAddress> = match list_event {
1143        Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1144            crate::log_debug!(
1145                "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1146                ev.created_at.as_secs(), our_last_publish,
1147            );
1148            local_addrs()
1149        }
1150        Some(ev) => decrypt_subscribed_addresses(client, &my_pubkey, &ev).await,
1151        None => {
1152            crate::log_debug!(
1153                "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1154            );
1155            local_addrs()
1156        }
1157    };
1158
1159    let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1160
1161    // Batched resolve: one home request for every subscribed pack, plus one
1162    // batched outbox pass for any not on our relays. Packs that still don't
1163    // resolve keep their cached copy (we never shrink the subscription set on
1164    // a transient miss). The per-pack `fetch_pack_from_relays` is reserved for
1165    // the independent preview/theme flows.
1166    let fresh: Vec<EmojiPack> = fetch_packs_from_relays(client, &addrs).await;
1167    let fetch_failures = addrs.len().saturating_sub(fresh.len());
1168    if fetch_failures > 0 {
1169        crate::log_warn!(
1170            "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1171            fetch_failures,
1172        );
1173    }
1174
1175    if !session.is_valid() {
1176        return Ok(fresh);
1177    }
1178
1179    for pack in &fresh {
1180        if let Err(e) = save_pack(pack) {
1181            crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1182        }
1183    }
1184    // Persist the full subscription list (10030-driven, or local-mirror
1185    // when 10030 was missing). Per-pack fetch failures don't shrink it —
1186    // the user is still subscribed, they just have a cached copy for now.
1187    if let Err(e) = save_subscriptions(&addr_strings) {
1188        crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1189    }
1190
1191    crate::log_info!(
1192        "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1193        fresh.len(),
1194        addrs.len(),
1195        if fetch_failures > 0 {
1196            format!(" ({} via cache)", fetch_failures)
1197        } else {
1198            String::new()
1199        },
1200    );
1201
1202    // Return the unified view: freshly-fetched packs overlay the cached
1203    // ones, and load_all_packs filters to subscribed-only for us.
1204    load_all_packs()
1205}
1206
1207/// Convenience entry point that grabs the client + my_pubkey internally
1208/// and runs the full subscribed-packs refresh. Intended for the boot
1209/// path; in-app commands pass an explicit `SessionGuard` via the lower
1210/// helper to make the safety contract visible at every call site.
1211pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1212    let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1213    let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1214    let session = crate::state::SessionGuard::capture();
1215    fetch_subscribed_packs(&client, me, session).await
1216}
1217
1218/// Preview-only fetch by naddr — resolves + parses but never touches
1219/// local DB. Lets the UI render a "Pack Preview" card without committing
1220/// to a subscription.
1221pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1222    let addr = parse_naddr(naddr)?;
1223    let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1224    fetch_pack_from_relays(&client, &addr).await
1225        .ok_or_else(|| format!("Pack not found on any relay: {}:{}", addr.pubkey.to_hex(), addr.identifier))
1226}
1227
1228/// Resolve a theme pack cache-first: return the locally-persisted copy
1229/// instantly when present (and refresh it in the background), otherwise fetch
1230/// live, persist, and return. Theme packs are pinned by the active theme, not
1231/// subscribed — `save_pack` persists their data without a subscription row, so
1232/// they survive restarts (no per-session relay round-trip) yet never occupy an
1233/// equip slot or land in the kind-10030 list. Returns `None` if uncached and
1234/// the live fetch finds nothing.
1235pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1236    let addr = parse_naddr(naddr)?;
1237    let coord = addr.to_addr_string();
1238
1239    // Cache hit: return immediately, refresh in the background so a later
1240    // creator-side edit still propagates without blocking first paint.
1241    if let Some(cached) = load_cached_pack(&coord)? {
1242        let naddr_owned = naddr.to_string();
1243        let session = crate::state::SessionGuard::capture();
1244        tokio::spawn(async move {
1245            if !session.is_valid() { return; }
1246            let Some(client) = nostr_client() else { return };
1247            if let Ok(parsed) = parse_naddr(&naddr_owned) {
1248                if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1249                    if session.is_valid() && fresh.updated_at > cached.updated_at {
1250                        if let Err(e) = save_pack(&fresh) {
1251                            crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1252                        } else {
1253                            crate::traits::emit_event("emoji_packs_updated", &());
1254                        }
1255                    }
1256                }
1257            }
1258        });
1259        return Ok(Some(cached));
1260    }
1261
1262    // Cache miss: fetch live, persist for next session, return.
1263    let session = crate::state::SessionGuard::capture();
1264    let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1265    match fetch_pack_from_relays(&client, &addr).await {
1266        Some(pack) => {
1267            // Still show the pack this session, but only persist if the account
1268            // didn't swap during the fetch — otherwise we'd write into the wrong
1269            // account's DB.
1270            if session.is_valid() {
1271                if let Err(e) = save_pack(&pack) {
1272                    crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1273                }
1274            }
1275            Ok(Some(pack))
1276        }
1277        None => Ok(None),
1278    }
1279}
1280
1281/// Publish a kind 10030 "Emojis" list containing every subscribed pack.
1282///
1283/// Encrypted-items mode: the entire subscription set lives inside a
1284/// NIP-44-self-encrypted JSON array of `["a", "30030:pk:d"]` tuples
1285/// stored in `content`. The event's public `tags` field is left empty
1286/// — Vector treats which packs a user follows as private information,
1287/// matching the NIP-51 "encrypted items" pattern that mute lists use.
1288/// Replaceable per spec, so peers (the same npub on another device)
1289/// always read the freshest set on next sync.
1290pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1291    let addrs = load_subscriptions()?;
1292    let my_pk = crate::state::my_public_key()
1293        .ok_or_else(|| "Not logged in".to_string())?;
1294
1295    let inner_tags: Vec<Vec<String>> = addrs.iter()
1296        .map(|addr| vec!["a".to_string(), addr.clone()])
1297        .collect();
1298    let plaintext = serde_json::to_string(&inner_tags)
1299        .map_err(|e| format!("Serialise emoji list: {}", e))?;
1300
1301    let signer = client.signer().await
1302        .map_err(|e| format!("Signer unavailable: {}", e))?;
1303    let content = signer.nip44_encrypt(&my_pk, &plaintext).await
1304        .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1305
1306    let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1307    client.send_event_builder(builder).await
1308        .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1309
1310    crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1311    Ok(())
1312}
1313
1314/// Settings key holding the UNIX-seconds timestamp of our most recent local
1315/// subscription mutation. A refresh ignores any relay kind-10030 older than
1316/// this so our just-changed (not-yet-propagated) list can't be clobbered.
1317const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1318
1319static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1320
1321/// Coalesce rapid subscribe/unsubscribe taps into one network publish.
1322/// Captures `SessionGuard` BEFORE the spawn boundary so a mid-debounce
1323/// account swap can't sign account A's pack list with account B's key.
1324pub fn republish_emoji_list_debounced() {
1325    use std::sync::atomic::Ordering;
1326    // Stamp the mutation time NOW (synchronously, before the debounce sleep)
1327    // so a refresh racing the not-yet-fired publish still treats the local
1328    // set as newer than any stale relay copy. Every local subscription change
1329    // funnels through here; the refresh-persist path does not, so this can't
1330    // wrongly suppress a legit cross-device update.
1331    let _ = crate::db::settings::set_sql_setting(
1332        EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1333        Timestamp::now().as_secs().to_string(),
1334    );
1335    let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1336    let session = crate::state::SessionGuard::capture();
1337    tokio::spawn(async move {
1338        tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1339        if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1340        if !session.is_valid() { return; }
1341        let client = match nostr_client() {
1342            Some(c) => c,
1343            None => return,
1344        };
1345        if let Err(e) = publish_emoji_list(&client).await {
1346            crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1347            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1348            if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1349            if !session.is_valid() { return; }
1350            if let Err(e2) = publish_emoji_list(&client).await {
1351                crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1352            }
1353        }
1354    });
1355}
1356
1357/// Subscribe to a pack by naddr: fetch the pack, persist it + the
1358/// subscription, then schedule a debounced republish of kind 10030.
1359/// Returns the hydrated pack on success.
1360pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1361    let session = crate::state::SessionGuard::capture();
1362    let pack = fetch_pack_by_naddr(naddr).await?;
1363    if !session.is_valid() {
1364        return Err("Account swapped during fetch — aborted".to_string());
1365    }
1366
1367    // Equipped-pack cap. Idempotent re-subscribe to a pack we already
1368    // have stays free; only adding a brand-new addr counts toward the limit.
1369    let pack_addr = pack.addr();
1370    {
1371        let existing_subs = load_subscriptions()?;
1372        let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
1373        let cap = effective_max_equipped_packs();
1374        if is_new && existing_subs.len() >= cap {
1375            return Err(format!(
1376                "You can equip at most {} packs. Remove one to add another.",
1377                cap,
1378            ));
1379        }
1380    }
1381
1382    save_pack(&pack)?;
1383
1384    let mut subs = load_subscriptions()?;
1385    if !subs.iter().any(|a| a == &pack_addr) {
1386        subs.push(pack_addr.clone());
1387    }
1388    if !session.is_valid() {
1389        return Err("Account swapped before subscription save — aborted".to_string());
1390    }
1391    save_subscriptions(&subs)?;
1392
1393    republish_emoji_list_debounced();
1394    crate::traits::emit_event("emoji_packs_updated", &());
1395
1396    Ok(pack)
1397}
1398
1399// ============================================================================
1400// Pack publish (own creator path)
1401// ============================================================================
1402
1403/// Build a kind 30030 EventBuilder for one of the user's own packs.
1404/// Dual-writes the NIP-51 spec tags (`title`/`image`/`description`)
1405/// alongside the Ditto-style (`name`/`picture`/`about`) tags so packs
1406/// interop with both ecosystems — see `MEMORY.md` plan notes.
1407fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1408    if pack.identifier.is_empty() {
1409        return Err("pack identifier required".to_string());
1410    }
1411    let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1412        .tag(Tag::custom(TagKind::custom("d"), [pack.identifier.clone()]));
1413
1414    // Spec-compliant metadata (NIP-51).
1415    if !pack.title.is_empty() {
1416        builder = builder
1417            .tag(Tag::custom(TagKind::custom("title"), [pack.title.clone()]))
1418            .tag(Tag::custom(TagKind::custom("name"), [pack.title.clone()]));
1419    }
1420    if !pack.image_url.is_empty() {
1421        builder = builder
1422            .tag(Tag::custom(TagKind::custom("image"), [pack.image_url.clone()]))
1423            .tag(Tag::custom(TagKind::custom("picture"), [pack.image_url.clone()]));
1424    }
1425    if !pack.description.is_empty() {
1426        builder = builder
1427            .tag(Tag::custom(TagKind::custom("description"), [pack.description.clone()]))
1428            .tag(Tag::custom(TagKind::custom("about"), [pack.description.clone()]));
1429    }
1430
1431    for e in &pack.emojis {
1432        if e.shortcode.is_empty() || e.url.is_empty() { continue; }
1433        builder = builder.tag(Tag::custom(
1434            TagKind::custom("emoji"),
1435            [e.shortcode.clone(), e.url.clone()],
1436        ));
1437    }
1438    Ok(builder)
1439}
1440
1441/// Publish (or replace) one of the user's own packs as a kind 30030
1442/// event, persist it locally, and add it to the subscription list so
1443/// the picker surfaces it immediately. SessionGuard-gated so a mid-
1444/// network account swap can't push account A's pack signed by B's key.
1445pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
1446    let session = crate::state::SessionGuard::capture();
1447    let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1448    let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1449
1450    // Per-pack emoji cap. Applies to own packs only — shared packs the
1451    // user receives can exceed this, the display layer truncates.
1452    let emoji_cap = effective_max_emojis_per_pack();
1453    if pack.emojis.len() > emoji_cap {
1454        return Err(format!(
1455            "A pack can hold at most {} emojis.",
1456            emoji_cap,
1457        ));
1458    }
1459
1460    // Force `pubkey` + `is_own` regardless of caller — protects against
1461    // a malformed payload claiming ownership of someone else's pack.
1462    let mut to_save = pack.clone();
1463    to_save.pubkey = my_pk.to_hex();
1464    to_save.is_own = true;
1465    let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
1466    to_save.id = naddr_from_addr(&raw_addr)?;
1467    to_save.updated_at = std::time::SystemTime::now()
1468        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1469
1470    // Equipped-pack cap. Replacing an existing own pack is fine — only
1471    // a *new* identifier would push us over the limit.
1472    {
1473        let existing_subs = load_subscriptions()?;
1474        let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
1475        let cap = effective_max_equipped_packs();
1476        if is_new && existing_subs.len() >= cap {
1477            return Err(format!(
1478                "You can equip at most {} packs. Remove one to add another.",
1479                cap,
1480            ));
1481        }
1482    }
1483
1484    let builder = build_pack_event(&to_save)?;
1485    client.send_event_builder(builder).await
1486        .map_err(|e| format!("publish kind 30030: {}", e))?;
1487
1488    if !session.is_valid() {
1489        return Err("Account swapped during publish — local state untouched".to_string());
1490    }
1491
1492    save_pack(&to_save)?;
1493
1494    // Add to local subscriptions so the picker shows it without waiting
1495    // for the next 10030 republish to land.
1496    let mut subs = load_subscriptions()?;
1497    if !subs.iter().any(|a| a == &raw_addr) {
1498        subs.push(raw_addr.clone());
1499        if !session.is_valid() {
1500            return Err("Account swapped before subscription save — aborted".to_string());
1501        }
1502        save_subscriptions(&subs)?;
1503        republish_emoji_list_debounced();
1504    }
1505
1506    crate::traits::emit_event("emoji_packs_updated", &());
1507    crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
1508        to_save.identifier, to_save.emojis.len());
1509
1510    Ok(to_save)
1511}
1512
1513/// Tombstone one of the user's own packs by publishing an empty kind
1514/// 30030 with just the `d` tag (relays replace the prior payload), drop
1515/// the local subscription, and republish kind 10030.
1516pub async fn delete_own_pack(id: &str) -> Result<(), String> {
1517    let session = crate::state::SessionGuard::capture();
1518    let parsed = parse_naddr(id)?;
1519    let raw_addr = parsed.to_addr_string();
1520    let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1521    if parsed.pubkey != my_pk {
1522        return Err("Cannot delete a pack you don't own".to_string());
1523    }
1524    let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1525
1526    let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1527        .tag(Tag::custom(TagKind::custom("d"), [parsed.identifier.clone()]));
1528    client.send_event_builder(builder).await
1529        .map_err(|e| format!("publish empty kind 30030: {}", e))?;
1530
1531    if !session.is_valid() {
1532        return Err("Account swapped during delete — local state untouched".to_string());
1533    }
1534
1535    // Drop subscription + pack rows (CASCADE wipes pack items).
1536    // Wrapped in a transaction so a crash between the two deletes can't
1537    // leave an orphan subscription pointing at a pack row that's already gone.
1538    {
1539        let mut conn = crate::db::get_write_connection_guard_static()?;
1540        let tx = conn.transaction()
1541            .map_err(|e| format!("begin delete tx: {}", e))?;
1542        tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
1543            rusqlite::params![raw_addr])
1544            .map_err(|e| format!("drop subscription: {}", e))?;
1545        tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
1546            rusqlite::params![raw_addr])
1547            .map_err(|e| format!("drop pack row: {}", e))?;
1548        tx.commit()
1549            .map_err(|e| format!("commit delete tx: {}", e))?;
1550    }
1551
1552    republish_emoji_list_debounced();
1553    crate::traits::emit_event("emoji_packs_updated", &());
1554    crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
1555    Ok(())
1556}
1557
1558/// Unsubscribe locally and republish kind 10030 without the pack.
1559/// The pack row itself stays in `emoji_packs` (caller may still want
1560/// to render old reactions); only the subscription link is dropped.
1561pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
1562    let session = crate::state::SessionGuard::capture();
1563    let raw_addr = parse_naddr(id)?.to_addr_string();
1564    let mut subs = load_subscriptions()?;
1565    let before = subs.len();
1566    subs.retain(|a| a != &raw_addr);
1567    if subs.len() == before {
1568        return Ok(()); // not subscribed, noop
1569    }
1570    if !session.is_valid() {
1571        return Err("Account swapped before unsubscribe save — aborted".to_string());
1572    }
1573    save_subscriptions(&subs)?;
1574    republish_emoji_list_debounced();
1575    crate::traits::emit_event("emoji_packs_updated", &());
1576    Ok(())
1577}
1578
1579// ============================================================================
1580// Tests
1581// ============================================================================
1582
1583#[cfg(test)]
1584mod tests {
1585    use super::*;
1586
1587    fn keys() -> Keys {
1588        Keys::generate()
1589    }
1590
1591    fn build_pack_event(
1592        k: &Keys,
1593        d: &str,
1594        title_tag: Option<(&str, &str)>,
1595        image_tag: Option<(&str, &str)>,
1596        desc_tag: Option<(&str, &str)>,
1597        emojis: &[(&str, &str)],
1598    ) -> Event {
1599        let mut tags: Vec<Tag> = Vec::new();
1600        tags.push(Tag::custom(TagKind::custom("d"), [d]));
1601        if let Some((key, val)) = title_tag {
1602            tags.push(Tag::custom(TagKind::custom(key), [val]));
1603        }
1604        if let Some((key, val)) = image_tag {
1605            tags.push(Tag::custom(TagKind::custom(key), [val]));
1606        }
1607        if let Some((key, val)) = desc_tag {
1608            tags.push(Tag::custom(TagKind::custom(key), [val]));
1609        }
1610        for (code, url) in emojis {
1611            tags.push(Tag::custom(TagKind::custom("emoji"), [*code, *url]));
1612        }
1613        EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1614            .tags(tags)
1615            .sign_with_keys(k)
1616            .unwrap()
1617    }
1618
1619    #[test]
1620    fn parse_pack_reads_nip51_spec_tags() {
1621        let k = keys();
1622        let ev = build_pack_event(
1623            &k, "myPack",
1624            Some(("title", "Spec Pack")),
1625            Some(("image", "https://example.com/p.png")),
1626            Some(("description", "specd")),
1627            &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
1628        );
1629        let pack = parse_pack_from_event(&ev, None).unwrap();
1630        assert_eq!(pack.identifier, "myPack");
1631        assert_eq!(pack.title, "Spec Pack");
1632        assert_eq!(pack.image_url, "https://example.com/p.png");
1633        assert_eq!(pack.description, "specd");
1634        assert_eq!(pack.emojis.len(), 2);
1635        assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
1636    }
1637
1638    #[test]
1639    fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
1640        let k = keys();
1641        let ev = build_pack_event(
1642            &k, "ditto",
1643            Some(("name", "Ditto Pack")),
1644            Some(("picture", "https://example.com/d.png")),
1645            Some(("about", "ditto-style")),
1646            &[("yes", "https://e.x/y.png")],
1647        );
1648        let pack = parse_pack_from_event(&ev, None).unwrap();
1649        assert_eq!(pack.title, "Ditto Pack");
1650        assert_eq!(pack.image_url, "https://example.com/d.png");
1651        assert_eq!(pack.description, "ditto-style");
1652    }
1653
1654    #[test]
1655    fn parse_pack_prefers_spec_tags_over_ditto() {
1656        let k = keys();
1657        let mut tags: Vec<Tag> = vec![
1658            Tag::custom(TagKind::custom("d"), ["both"]),
1659            Tag::custom(TagKind::custom("title"), ["SpecTitle"]),
1660            Tag::custom(TagKind::custom("name"), ["DittoName"]),
1661            Tag::custom(TagKind::custom("image"), ["spec.png"]),
1662            Tag::custom(TagKind::custom("picture"), ["ditto.png"]),
1663            Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
1664        ];
1665        tags.extend(std::iter::empty());
1666        let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1667            .tags(tags).sign_with_keys(&k).unwrap();
1668        let pack = parse_pack_from_event(&ev, None).unwrap();
1669        assert_eq!(pack.title, "SpecTitle");
1670        assert_eq!(pack.image_url, "spec.png");
1671    }
1672
1673    #[test]
1674    fn parse_pack_returns_none_without_d_tag() {
1675        let k = keys();
1676        let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1677            .tags(vec![
1678                Tag::custom(TagKind::custom("title"), ["No D"]),
1679                Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
1680            ])
1681            .sign_with_keys(&k).unwrap();
1682        assert!(parse_pack_from_event(&ev, None).is_none());
1683    }
1684
1685    #[test]
1686    fn parse_pack_returns_none_when_no_valid_emojis() {
1687        let k = keys();
1688        let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
1689        assert!(parse_pack_from_event(&ev, None).is_none());
1690    }
1691
1692    #[test]
1693    fn parse_pack_rejects_invalid_shortcodes() {
1694        let k = keys();
1695        let ev = build_pack_event(
1696            &k, "mix", Some(("title", "Mix")), None, None,
1697            &[("ok_name", "https://e.x/a.png"),
1698              ("bad name", "https://e.x/b.png"),
1699              ("colons:no", "https://e.x/c.png"),
1700              ("", "https://e.x/d.png"),
1701              ("dash-ok", "https://e.x/e.png")],
1702        );
1703        let pack = parse_pack_from_event(&ev, None).unwrap();
1704        let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
1705        assert_eq!(codes, vec!["ok_name", "dash-ok"]);
1706    }
1707
1708    #[test]
1709    fn parse_pack_dedupes_shortcodes_first_wins() {
1710        let k = keys();
1711        let ev = build_pack_event(
1712            &k, "dup", Some(("title", "Dup")), None, None,
1713            &[("smile", "https://e.x/first.png"),
1714              ("smile", "https://e.x/second.png")],
1715        );
1716        let pack = parse_pack_from_event(&ev, None).unwrap();
1717        assert_eq!(pack.emojis.len(), 1);
1718        assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
1719    }
1720
1721    #[test]
1722    fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
1723        let k = keys();
1724        let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
1725            &[("a", "https://e.x/a.png")]);
1726        let my_hex = k.public_key().to_hex();
1727        let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
1728        assert!(pack_mine.is_own);
1729
1730        let stranger = keys().public_key().to_hex();
1731        let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
1732        assert!(!pack_other.is_own);
1733    }
1734
1735    #[test]
1736    fn pack_address_to_string_round_trips() {
1737        let k = keys();
1738        let hex = k.public_key().to_hex();
1739        let addr = PackAddress {
1740            kind: 30030,
1741            pubkey: k.public_key(),
1742            identifier: "myPack".to_string(),
1743        };
1744        assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
1745        let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
1746        assert_eq!(parsed, addr);
1747    }
1748
1749    #[test]
1750    fn parse_naddr_round_trips_kind_30030_coordinate() {
1751        // Construct a synthetic naddr via nostr-sdk and verify our decoder
1752        // round-trips kind / pubkey / identifier.
1753        let k = keys();
1754        let coord = nostr_sdk::nips::nip01::Coordinate {
1755            kind: Kind::Custom(30030),
1756            public_key: k.public_key(),
1757            identifier: "trip".to_string(),
1758        };
1759        let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
1760            coordinate: coord,
1761            relays: Vec::new(),
1762        };
1763        let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
1764        let parsed = parse_naddr(&naddr).unwrap();
1765        assert_eq!(parsed.kind, 30030);
1766        assert_eq!(parsed.pubkey, k.public_key());
1767        assert_eq!(parsed.identifier, "trip");
1768    }
1769
1770    #[test]
1771    fn parse_naddr_rejects_non_30030_kinds() {
1772        let k = keys();
1773        let coord = nostr_sdk::nips::nip01::Coordinate {
1774            kind: Kind::Custom(30023), // long-form article
1775            public_key: k.public_key(),
1776            identifier: "essay".to_string(),
1777        };
1778        let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
1779            coordinate: coord,
1780            relays: Vec::new(),
1781        };
1782        let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
1783        let err = parse_naddr(&naddr).unwrap_err();
1784        assert!(err.contains("expected kind 30030"),
1785            "expected kind-rejection error, got: {}", err);
1786    }
1787
1788    #[test]
1789    fn parse_naddr_strips_nostr_uri_prefix() {
1790        let k = keys();
1791        let coord = nostr_sdk::nips::nip01::Coordinate {
1792            kind: Kind::Custom(30030),
1793            public_key: k.public_key(),
1794            identifier: "prefixed".to_string(),
1795        };
1796        let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
1797            coordinate: coord,
1798            relays: Vec::new(),
1799        };
1800        let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
1801        let with_prefix = format!("nostr:{}", naddr);
1802        let parsed = parse_naddr(&with_prefix).unwrap();
1803        assert_eq!(parsed.identifier, "prefixed");
1804    }
1805
1806    #[test]
1807    fn parse_naddr_rejects_garbage_input() {
1808        assert!(parse_naddr("not an naddr").is_err());
1809        assert!(parse_naddr("naddr1invalid").is_err());
1810        assert!(parse_naddr("").is_err());
1811    }
1812
1813    #[test]
1814    fn parse_pack_address_round_trips_valid_input() {
1815        let k = keys();
1816        let hex = k.public_key().to_hex();
1817        let addr = format!("30030:{}:myId", hex);
1818        let parsed = parse_pack_address(&addr).unwrap();
1819        assert_eq!(parsed.kind, 30030);
1820        assert_eq!(parsed.pubkey, k.public_key());
1821        assert_eq!(parsed.identifier, "myId");
1822    }
1823
1824    #[test]
1825    fn parse_pack_address_rejects_wrong_kind() {
1826        let hex = keys().public_key().to_hex();
1827        let addr = format!("10030:{}:x", hex);
1828        assert!(parse_pack_address(&addr).is_err());
1829    }
1830
1831    #[test]
1832    fn parse_pack_address_rejects_malformed_pubkey() {
1833        let addr = "30030:not-hex:x".to_string();
1834        assert!(parse_pack_address(&addr).is_err());
1835    }
1836
1837    #[test]
1838    fn parse_pack_address_preserves_colons_in_identifier() {
1839        // d-tag values can be arbitrary strings, including colons.
1840        let hex = keys().public_key().to_hex();
1841        let addr = format!("30030:{}:id:with:colons", hex);
1842        let parsed = parse_pack_address(&addr).unwrap();
1843        assert_eq!(parsed.identifier, "id:with:colons");
1844    }
1845
1846    #[test]
1847    fn parse_inner_tag_list_extracts_valid_a_tags() {
1848        // The inner tag list lives JSON-encoded inside the NIP-44-encrypted
1849        // event content; exercise the parser directly so we don't pull a
1850        // signer + network into a unit test.
1851        let hex_a = keys().public_key().to_hex();
1852        let hex_b = keys().public_key().to_hex();
1853        let plaintext = format!(
1854            r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
1855            a = hex_a,
1856            b = hex_b,
1857        );
1858        let addrs = parse_inner_tag_list(&plaintext);
1859        assert_eq!(addrs.len(), 2);
1860        assert_eq!(addrs[0].identifier, "packA");
1861        assert_eq!(addrs[1].identifier, "packB");
1862    }
1863
1864    #[test]
1865    fn parse_inner_tag_list_returns_empty_on_malformed_json() {
1866        assert!(parse_inner_tag_list("not json").is_empty());
1867        assert!(parse_inner_tag_list("").is_empty());
1868    }
1869
1870    #[test]
1871    fn shortcode_validator_accepts_alphanum_dash_underscore() {
1872        assert!(is_valid_shortcode("smile"));
1873        assert!(is_valid_shortcode("smile_face"));
1874        assert!(is_valid_shortcode("smile-face"));
1875        assert!(is_valid_shortcode("Smile2"));
1876        assert!(!is_valid_shortcode(""));
1877        assert!(!is_valid_shortcode("smile face"));
1878        assert!(!is_valid_shortcode("smile:face"));
1879        assert!(!is_valid_shortcode("😀"));
1880        // `~` is reserved for message-tag disambiguation, NOT valid in
1881        // pack-authored shortcodes.
1882        assert!(!is_valid_shortcode("love~2"));
1883    }
1884
1885    #[test]
1886    fn resolve_token_disambiguates_duplicate_shortcodes() {
1887        // Two distinct images share `:love:`, sorted lexicographically by URL.
1888        let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
1889        by_code.insert(
1890            "love".to_string(),
1891            vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
1892        );
1893        by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
1894
1895        // Plain code → first candidate (matches a bare `:love:`).
1896        assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
1897        // `~1` / `~2` select by 1-based index.
1898        assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
1899        assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
1900        // Out-of-range / zero / unknown base → nothing (renders literal).
1901        assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
1902        assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
1903        assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
1904        // Non-colliding code resolves bare; a stray `~N` on it is out of range.
1905        assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
1906        assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
1907    }
1908
1909    #[test]
1910    fn message_emoji_tags_accept_disambiguation_separator() {
1911        // Inbound `love~2` tags must survive parsing so the recipient renders.
1912        let tags = vec![
1913            vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
1914            vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
1915        ];
1916        let out = crate::types::EmojiTag::extract_from_stored(&tags);
1917        assert_eq!(out.len(), 1);
1918        assert_eq!(out[0].shortcode, "love~2");
1919    }
1920}