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;
21use crate::ClientRelayExt;
22
23/// NIP-51 kind for a user's "Emojis" list (replaceable, per-user).
24const KIND_EMOJI_LIST: u16 = 10030;
25
26/// NIP-51 kind for an "Emoji set" (parameterised replaceable).
27pub const KIND_EMOJI_SET: u16 = 30030;
28
29/// Wire-form prefix for kind 30030 `a` tags / DB primary keys.
30/// `format!("{}:…", KIND_EMOJI_SET)` is the obvious construction but
31/// `format!` is heavy (Display dispatch, intermediate capacity probing);
32/// for an addr that's read on every pack save / load / subscribe, hold
33/// it as a literal and assert at compile time that the kind matches.
34const KIND_EMOJI_SET_ADDR_PREFIX: &str = "30030:";
35const _: () = assert!(
36    KIND_EMOJI_SET == 30030,
37    "KIND_EMOJI_SET_ADDR_PREFIX literal must match KIND_EMOJI_SET"
38);
39
40/// Build a canonical `kind:pubkey:identifier` addr string with a single
41/// allocation sized exactly to the result. ~3× faster than the
42/// equivalent `format!` in microbenchmarks and has no fmt machinery on
43/// the hot path (load, save, subscribe all hit this).
44fn build_pack_addr(pubkey: &str, identifier: &str) -> String {
45    let mut s = String::with_capacity(
46        KIND_EMOJI_SET_ADDR_PREFIX.len() + pubkey.len() + 1 + identifier.len(),
47    );
48    s.push_str(KIND_EMOJI_SET_ADDR_PREFIX);
49    s.push_str(pubkey);
50    s.push(':');
51    s.push_str(identifier);
52    s
53}
54
55/// Network fetch budget for resolving the user's pack list + each pack.
56/// 8s was too tight in practice — a single slow relay handshake would
57/// flash "Pack Unavailable" at the user when the event was still on its
58/// way. 20s comfortably covers a cold Tor circuit / sleepy relay
59/// without making a genuinely-missing pack feel sluggish.
60const FETCH_TIMEOUT_SECS: u64 = 20;
61
62/// Per-effective-tier caps (index = `badges::effective_tier()`, 0-3). These gate
63/// only the in-app add action; packs subscribed via other clients always load in
64/// full and are never sliced. Tier 3 (full premium) is effectively unlimited for
65/// the count cap.
66const EQUIPPED_PACKS_BY_TIER: [usize; 4] = [3, 6, 9, usize::MAX];
67/// Per-pack emoji cap by tier (authoring + display). Full premium tops out at 90.
68const EMOJIS_PER_PACK_BY_TIER: [usize; 4] = [30, 30, 60, 90];
69
70/// Base (free, tier-0) equipped-pack cap. Named const for the frontend mirror.
71pub const MAX_EQUIPPED_PACKS: usize = EQUIPPED_PACKS_BY_TIER[0];
72
73/// Base (free, tier-0) emojis-per-own-pack cap. Shared packs received from the
74/// network may exceed this — only what *we* author is capped, and the frontend
75/// truncates oversized received packs at display time.
76pub const MAX_EMOJIS_PER_PACK: usize = EMOJIS_PER_PACK_BY_TIER[0];
77
78/// In-app equipped-pack cap for the current account, scaled by effective tier.
79/// Gate the in-app subscribe/create action on this — never the load/display path.
80pub fn effective_max_equipped_packs() -> usize {
81    EQUIPPED_PACKS_BY_TIER[crate::badges::effective_tier() as usize]
82}
83
84/// Per-pack emoji authoring cap for the current account, scaled by effective tier.
85pub fn effective_max_emojis_per_pack() -> usize {
86    EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
87}
88
89// ============================================================================
90// Types
91// ============================================================================
92
93#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
94pub struct PackEmoji {
95    pub shortcode: String,
96    pub url: String,
97    pub sha256: Option<String>,
98}
99
100#[derive(Serialize, Deserialize, Clone, Debug)]
101pub struct EmojiPack {
102    /// Canonical NIP-19 `naddr1...` for this pack (no relay hints).
103    /// Mirrors the `profile.id == npub` pattern — frontend / IPC code
104    /// only ever speaks bech32. Internal storage + kind 10030 `a` tags
105    /// still use the raw `kind:pubkey:identifier` coordinate (DB
106    /// columns named `addr`, helpers exposed via `parse_pack_address`
107    /// / `naddr_from_addr`).
108    pub id: String,
109    /// Author of the kind 30030 event (hex).
110    pub pubkey: String,
111    /// `d` tag identifier.
112    pub identifier: String,
113    /// NIP-51 `title` with Ditto `name` fallback.
114    pub title: String,
115    /// NIP-51 `image` with Ditto `picture` fallback. Empty if neither.
116    pub image_url: String,
117    /// NIP-51 `description` with Ditto `about` fallback.
118    pub description: String,
119    pub emojis: Vec<PackEmoji>,
120    /// Owned packs surface a different UI affordance (edit pencil).
121    pub is_own: bool,
122    /// Event `created_at` — fed back into the relay filter so re-fetches
123    /// don't process older events on top of a newer cached pack.
124    pub updated_at: u64,
125    /// Health verdict: [`PACK_STATUS_ACTIVE`] / [`PACK_STATUS_REVOKED`] /
126    /// [`PACK_STATUS_MISSING`]. Dead packs stay in the picker payload so the
127    /// UI can render the section greyed with an explanation + remove button,
128    /// but their emojis are excluded from sending and suggestions.
129    pub status: u8,
130}
131
132/// Pack is live on its relays (or hasn't been judged otherwise).
133pub const PACK_STATUS_ACTIVE: u8 = 0;
134/// A deterministic tombstone was seen: the author replaced the pack with an
135/// EMPTY kind 30030 (Vector's own delete flow) or published a kind-5
136/// deletion naming it.
137pub const PACK_STATUS_REVOKED: u8 = 1;
138/// No tombstone, but the pack has been absent across enough clean sweeps of
139/// live relays (see the gauntlet in [`apply_pack_health`]) that it's
140/// considered gone.
141pub const PACK_STATUS_MISSING: u8 = 2;
142
143/// What one refresh sweep learned about a subscribed pack.
144#[derive(Debug, Clone, PartialEq)]
145pub enum PackFetchOutcome {
146    /// A live (non-empty) pack event resolved.
147    Found,
148    /// Deterministic deletion evidence — no gauntlet needed.
149    Tombstoned,
150    /// Enough live relays answered EOSE and none had the pack.
151    CleanMiss,
152    /// Not enough connectivity to judge; counters must not move.
153    Unreachable,
154}
155
156/// Absence promotes to `missing` only after this many rate-limited clean
157/// misses AND [`PACK_MISS_PROMOTE_SECS`] since the first — strict enough
158/// that a weekend relay outage never flags a healthy pack.
159const PACK_MISS_PROMOTE_COUNT: i64 = 3;
160const PACK_MISS_PROMOTE_SECS: i64 = 48 * 3600;
161/// A clean miss moves the counter at most once per this window, so rapid
162/// app restarts can't fabricate the miss count.
163const PACK_MISS_RATELIMIT_SECS: i64 = 12 * 3600;
164
165impl EmojiPack {
166    /// Raw NIP-51 coordinate (`kind:pubkey:identifier`). Used for kind
167    /// 10030 `a` tags + DB keying. One pre-sized allocation, no fmt
168    /// machinery (see `build_pack_addr`).
169    pub fn addr(&self) -> String {
170        build_pack_addr(&self.pubkey, &self.identifier)
171    }
172}
173
174// ============================================================================
175// Parsing
176// ============================================================================
177
178/// Validate a NIP-30 shortcode: `[a-zA-Z0-9_-]+`, non-empty. Anything
179/// else would render brokenly against the `:[\w-]+:` regex our renderer
180/// uses, so we drop invalid items at parse time.
181fn is_valid_shortcode(s: &str) -> bool {
182    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
183}
184
185/// Active theme pack's `(shortcode, url)` pairs, registered from the frontend.
186/// The theme pack is shown in the picker without being a real subscription, so
187/// its shortcodes never land in `emoji_pack_items`; this lets the send resolver
188/// still attach NIP-30 tags for them. Replaced wholesale on theme change.
189/// Per-account: leaving the prior account's set active would tag the next
190/// account's outbound messages with A's shortcodes, leaking A's pack URLs.
191struct ThemeEmojiTags;
192
193fn theme_emoji_tags() -> std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> {
194    crate::db::current_session().scoped::<ThemeEmojiTags, _>()
195}
196
197/// Every shortcode that renders as an image for this account. A `:code:` outside
198/// this set reaches the reader as literal text, so text analysis must keep it.
199pub fn known_shortcodes() -> std::collections::HashSet<String> {
200    let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
201    if let Ok(conn) = crate::db::get_db_connection_guard_static() {
202        if let Ok(mut stmt) = conn.prepare("SELECT DISTINCT shortcode FROM emoji_pack_items") {
203            if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) {
204                out.extend(rows.flatten());
205            }
206        }
207    }
208    if let Ok(theme) = theme_emoji_tags().lock() {
209        out.extend(theme.iter().map(|(code, _)| code.clone()));
210    }
211    out
212}
213
214/// Register (or clear, with an empty vec) the active theme pack's emoji so the
215/// send resolver can tag them even though they aren't a DB subscription.
216pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
217    if let Ok(mut g) = theme_emoji_tags().lock() {
218        *g = tags;
219    }
220}
221
222/// Per-pack emoji cap mirrored from the frontend's `MAX_DISPLAY_EMOJIS_PER_PACK`
223/// (`applyBadgeLimits`). The send resolver caps each subscribed pack to this
224/// many items so its `~N` disambiguation of duplicate shortcodes matches exactly
225/// what the picker displayed — otherwise a large pack's hidden tail (the picker
226/// only shows the first N) would enter the candidate set and shift the indices.
227pub fn effective_display_cap() -> usize {
228    EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
229}
230
231/// Resolve a (possibly `~N`-suffixed) shortcode token against the candidate map.
232/// Plain `base` → the first candidate; `base~N` → the N-th (1-based) candidate
233/// in the same URL-sorted order the picker assigns. A `~N` whose base is unknown
234/// or whose index is out of range yields nothing (renders as literal text).
235fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
236    if let Some((base, suffix)) = token.rsplit_once('~') {
237        let n: usize = suffix.parse().ok()?;
238        if n == 0 { return None; }
239        return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
240    }
241    by_code.get(token).and_then(|urls| urls.first().cloned())
242}
243
244/// Scan `content` for `:shortcode:` patterns and resolve them against the user's
245/// currently-subscribed packs (plus the active theme pack). Returns deduped emoji
246/// tags in first-match order. Used by the send pipeline to attach NIP-30 emoji
247/// tags so recipients without the pack subscribed still render.
248///
249/// Duplicate shortcodes across packs are disambiguated `base~N` (1-based) by a
250/// lexicographic URL sort — the SAME ordering the frontend's `_assignEmojiDisambig`
251/// uses — so a `:love~2:` the picker inserted resolves here to the same image.
252pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
253    if content.is_empty() || !content.contains(':') {
254        return Vec::new();
255    }
256
257    // base shortcode -> ordered candidate URLs (one per distinct image).
258    let cap = effective_display_cap();
259    let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
260
261    // Subscribed packs. INNER JOIN matches `load_all_packs` — soft-removed own
262    // packs shouldn't leak their Blossom URLs through outbound tags when the
263    // user types a shortcode they thought was hidden. Dead packs (revoked /
264    // missing) are excluded too: their emojis are retired from sending, not
265    // just from the picker.
266    if let Ok(conn) = crate::db::get_db_connection_guard_static() {
267        if let Ok(mut stmt) = conn.prepare(
268            "SELECT p.addr, i.shortcode, i.url
269             FROM emoji_pack_items i
270             INNER JOIN emoji_packs p ON p.addr = i.pack_addr
271             INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
272             WHERE p.status = 0
273             ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
274        ) {
275            if let Ok(rows) = stmt.query_map([], |row| {
276                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
277            }) {
278                // Mirror the picker's per-pack display cap so hidden tail emojis
279                // don't enter (and skew) disambiguation.
280                let mut per_pack: HashMap<String, usize> = HashMap::new();
281                for (addr, code, url) in rows.flatten() {
282                    let n = per_pack.entry(addr).or_insert(0);
283                    if *n >= cap { continue; }
284                    *n += 1;
285                    by_code.entry(code).or_default().push(url);
286                }
287            }
288        }
289    }
290
291    // Active theme pack fills any gaps. It's shown in the picker without being
292    // a real subscription, so its shortcodes aren't in the DB — registered
293    // from the frontend via `set_theme_emoji_tags` (already capped there).
294    if let Ok(theme) = theme_emoji_tags().lock() {
295        for (code, url) in theme.iter() {
296            by_code.entry(code.clone()).or_default().push(url.clone());
297        }
298    }
299
300    if by_code.is_empty() {
301        return Vec::new();
302    }
303
304    // De-dup identical images + lexicographic sort so `~N` is stable and matches
305    // the frontend (two packs carrying the same URL collapse to one candidate).
306    for urls in by_code.values_mut() {
307        urls.sort();
308        urls.dedup();
309    }
310
311    let mut out: Vec<crate::types::EmojiTag> = Vec::new();
312    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
313    let bytes = content.as_bytes();
314    let mut i = 0;
315    while i < bytes.len() {
316        if bytes[i] == b':' {
317            let start = i + 1;
318            let mut j = start;
319            while j < bytes.len() {
320                let c = bytes[j];
321                // `~` is the reserved disambiguation separator (`:love~2:`).
322                let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
323                if !ok { break; }
324                j += 1;
325            }
326            if j > start && j < bytes.len() && bytes[j] == b':' {
327                if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
328                    if !seen.contains(token) {
329                        if let Some(url) = resolve_emoji_token(&by_code, token) {
330                            out.push(crate::types::EmojiTag {
331                                shortcode: token.to_string(),
332                                url,
333                            });
334                            seen.insert(token.to_string());
335                        }
336                    }
337                }
338                i = j + 1;
339                continue;
340            }
341        }
342        i += 1;
343    }
344    out
345}
346
347/// Fetch the first single-string tag whose key matches any of `keys`,
348/// in order. Used for the dual NIP-51 / Ditto metadata lookup.
349fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
350    for key in keys {
351        for tag in tags.iter() {
352            let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
353            if parts.len() >= 2 && parts[0] == *key {
354                return Some(parts[1].to_string());
355            }
356        }
357    }
358    None
359}
360
361/// Parse a kind 30030 event into an EmojiPack. Returns `None` if the
362/// event is missing a `d` tag or has zero valid emoji rows.
363pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
364    if event.kind.as_u16() != KIND_EMOJI_SET {
365        return None;
366    }
367
368    let identifier = first_tag(&event.tags, &["d"])?;
369    let pubkey = event.pubkey.to_hex();
370    let addr = build_pack_addr(&pubkey, &identifier);
371    let id = match naddr_from_addr(&addr) {
372        Ok(s) => s,
373        Err(e) => {
374            crate::log_warn!(
375                "[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
376                addr, e,
377            );
378            return None;
379        }
380    };
381
382    let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
383    let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
384    let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
385
386    let mut emojis = Vec::new();
387    let mut seen = std::collections::HashSet::new();
388    for tag in event.tags.iter() {
389        let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
390        if parts.len() >= 3 && parts[0] == "emoji" {
391            let shortcode = parts[1];
392            if !is_valid_shortcode(shortcode) { continue; }
393            // Trim URL whitespace: some packs in the wild carry a stray leading/trailing space
394            // (e.g. "…/x.gif "), which the WebView strips for a raw <img> but the cache fetch does
395            // NOT — leaving the emoji blank everywhere it's served from cache. Skip if empty after.
396            let url = parts[2].trim();
397            if url.is_empty() { continue; }
398            if !seen.insert(shortcode.to_string()) { continue; }
399            emojis.push(PackEmoji {
400                shortcode: shortcode.to_string(),
401                url: url.to_string(),
402                sha256: None,
403            });
404        }
405    }
406
407    if emojis.is_empty() {
408        return None;
409    }
410
411    let is_own = my_pubkey_hex.map_or(false, |me| me == pubkey);
412
413    Some(EmojiPack {
414        id,
415        pubkey,
416        identifier,
417        title,
418        image_url,
419        description,
420        emojis,
421        is_own,
422        updated_at: event.created_at.as_secs(),
423        status: PACK_STATUS_ACTIVE,
424    })
425}
426
427/// Parsed NIP-19 / NIP-51 set address.
428#[derive(Debug, Clone, PartialEq)]
429pub struct PackAddress {
430    pub kind: u16,
431    pub pubkey: PublicKey,
432    pub identifier: String,
433}
434
435/// Parse a `kind:pubkey-hex:d-tag` address as found in kind 10030 `a` tags.
436/// Rejects anything that isn't kind 30030 — we don't want a malformed list
437/// pulling in random replaceable events.
438pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
439    let mut parts = addr.splitn(3, ':');
440    let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
441    let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
442    let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
443
444    let kind: u16 = kind_str.parse()
445        .map_err(|_| format!("invalid kind: {}", kind_str))?;
446    if kind != KIND_EMOJI_SET {
447        return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
448    }
449    let pubkey = PublicKey::from_hex(pubkey_str)
450        .map_err(|e| format!("invalid pubkey: {}", e))?;
451
452    Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
453}
454
455impl PackAddress {
456    /// Serialise back to the wire form used in kind 10030 `a` tags.
457    /// `parse_pack_address` is the only constructor and it rejects any
458    /// kind ≠ KIND_EMOJI_SET, so we can route through the optimised
459    /// `build_pack_addr` and skip `format!` entirely.
460    pub fn to_addr_string(&self) -> String {
461        debug_assert_eq!(self.kind, KIND_EMOJI_SET,
462            "PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
463        build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
464    }
465}
466
467/// Mint the SHAREABLE form of a pack naddr: the same coordinate plus up to 3
468/// relay hints naming the pack's own home. A pack lives on its AUTHOR's write
469/// relays (NIP-65), never the sharer's — a subscribed pack must not get the
470/// sharer's personal set stamped onto it. An author with no published kind
471/// 10002 falls back to this client's pool, which is where an own pack was
472/// published and where a subscribed one was actually seen.
473pub async fn share_naddr(naddr: &str) -> Result<String, String> {
474    let coordinate = match Nip19::from_bech32(naddr) {
475        Ok(Nip19::Coordinate(c)) => c.coordinate,
476        _ => return Err("not a pack naddr".to_string()),
477    };
478    if coordinate.kind != Kind::Custom(KIND_EMOJI_SET) {
479        return Err("not an emoji pack coordinate".to_string());
480    }
481    let client = nostr_client().ok_or_else(|| "Nostr client not initialized".to_string())?;
482    let mut relays = fetch_author_write_relays(&client, coordinate.public_key).await;
483    if relays.is_empty() {
484        // Write-capable pool relays only: GOSSIP-only community relays are
485        // foreign infrastructure a pack never publishes to.
486        relays = client
487            .relays()
488            .await
489            .into_iter()
490            .filter(|(_, r)| r.capabilities().load().can_write())
491            .map(|(url, _)| url)
492            .collect();
493    }
494    relays.truncate(3);
495    if relays.is_empty() {
496        return Ok(naddr.to_string());
497    }
498    Nip19::Coordinate(Nip19Coordinate { coordinate, relays })
499        .to_bech32()
500        .map_err(|e| format!("encode naddr: {}", e))
501}
502
503/// Encode a pack `addr` (`kind:pubkey:identifier`) into a NIP-19
504/// `naddr1...` bech32 string. Used by the share-pack flow to put a
505/// portable reference on the user's clipboard.
506pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
507    let parsed = parse_pack_address(addr)?;
508    let coord = nostr_sdk::prelude::nip01::Coordinate {
509        kind: Kind::Custom(parsed.kind),
510        public_key: parsed.pubkey,
511        identifier: parsed.identifier,
512    };
513    let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
514        coordinate: coord,
515        relays: Vec::new(),
516    };
517    nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
518        .to_bech32()
519        .map_err(|e| format!("encode naddr: {}", e))
520}
521
522/// Decode a NIP-19 `naddr1...` into a `PackAddress`. Rejects coordinates
523/// that don't point at kind 30030 so a malformed paste can't pull in
524/// an unrelated replaceable event.
525pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
526    let trimmed = naddr.trim().trim_start_matches("nostr:");
527    let parsed = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed)
528        .map_err(|e| format!("invalid naddr: {}", e))?;
529    let coord = match parsed {
530        nostr_sdk::prelude::nip19::Nip19::Coordinate(c) => c,
531        _ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
532    };
533    let kind = coord.kind.as_u16();
534    if kind != KIND_EMOJI_SET {
535        return Err(format!(
536            "expected kind {} (emoji set), got {}",
537            KIND_EMOJI_SET, kind,
538        ));
539    }
540    Ok(PackAddress {
541        kind,
542        pubkey: coord.public_key,
543        identifier: coord.identifier.clone(),
544    })
545}
546
547/// One-element sentinel tuple (`["theme_slot"]`) marking WHERE the theme pack
548/// renders in the equipped-pack order. Carried inside the kind-10030 encrypted
549/// content so the slot position syncs across devices. Ignored by
550/// `parse_inner_tag_list` (keeps only `a` tags) and by every other Nostr client
551/// (content is NIP-44 self-encrypted), so it degrades gracefully.
552const THEME_SLOT_TOKEN: &str = "theme_slot";
553
554/// Parse a NIP-51 inner tag list (the JSON array of tag tuples that
555/// lives inside the NIP-44-encrypted `content` of an encrypted-items
556/// list). Pulls out `a` tags as pack addresses; malformed inner
557/// entries are dropped silently so one bad row doesn't nuke the list.
558fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
559    let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
560        Ok(v) => v,
561        Err(e) => {
562            crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
563            return Vec::new();
564        }
565    };
566    inner.into_iter()
567        .filter_map(|tup| {
568            if tup.len() >= 2 && tup[0] == "a" {
569                parse_pack_address(&tup[1]).ok()
570            } else {
571                None
572            }
573        })
574        .collect()
575}
576
577/// Like [`parse_inner_tag_list`], but preserves the raw addr order AND
578/// extracts the theme-slot anchor. The anchor is the raw addr of the pack the
579/// `["theme_slot"]` marker sits immediately after; `""` means the marker is at
580/// the top (before every pack). Returns `None` for the anchor when no marker
581/// tuple is present at all (an old-format list predating the theme-slot
582/// feature). Malformed `a` tags are dropped so one bad row can't nuke the list.
583fn parse_inner_tag_list_with_anchor(plaintext: &str) -> (Vec<String>, Option<String>) {
584    let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
585        Ok(v) => v,
586        Err(e) => {
587            crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
588            return (Vec::new(), None);
589        }
590    };
591    let mut addrs: Vec<String> = Vec::new();
592    let mut anchor: Option<String> = None;
593    for tup in inner {
594        if tup.len() >= 2 && tup[0] == "a" {
595            // Normalise through parse → to_addr_string so the stored anchor
596            // matches what load_subscriptions/save_subscriptions round-trip.
597            if let Ok(pa) = parse_pack_address(&tup[1]) {
598                addrs.push(pa.to_addr_string());
599            }
600        } else if anchor.is_none() && tup.first().map(String::as_str) == Some(THEME_SLOT_TOKEN) {
601            // Anchor = last real addr before the marker, or "" (top) if the
602            // marker precedes every pack. First marker wins on a malformed list.
603            anchor = Some(addrs.last().cloned().unwrap_or_default());
604        }
605    }
606    (addrs, anchor)
607}
608
609/// Decrypt + parse a kind 10030 event's encrypted subscription list.
610///
611/// Vector's emoji list is fully private by design — every `a` tag is
612/// carried inside the NIP-44-self-encrypted `content`, never in the
613/// public `tags` field. A list event with empty / undecryptable /
614/// malformed content is treated as "no subscriptions" rather than
615/// failing the whole refresh. Spec: NIP-51 "encrypted items" section.
616pub async fn decrypt_subscribed_addresses(
617    client: &Client,
618    my_pk: &PublicKey,
619    event: &Event,
620) -> Vec<PackAddress> {
621    match decrypt_emoji_list_plaintext(client, my_pk, event).await {
622        Some(plaintext) => parse_inner_tag_list(&plaintext),
623        None => Vec::new(),
624    }
625}
626
627/// NIP-44-self-decrypt a kind 10030 event's content. `None` = empty /
628/// undecryptable / no-signer (all treated as "no subscriptions").
629async fn decrypt_emoji_list_plaintext(
630    _client: &Client,
631    my_pk: &PublicKey,
632    event: &Event,
633) -> Option<String> {
634    if event.content.is_empty() {
635        return None;
636    }
637    let signer = match crate::signer::active_signer() {
638        Ok(s) => s,
639        Err(e) => {
640            crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
641            return None;
642        }
643    };
644    match signer.nip44_decrypt_async(my_pk, &event.content).await {
645        Ok(p) => Some(p),
646        Err(e) => {
647            crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
648            None
649        }
650    }
651}
652
653/// Like [`decrypt_subscribed_addresses`], but also returns the theme-slot
654/// anchor carried in the encrypted list (`None` = old-format list, no marker).
655async fn decrypt_subscribed_addresses_with_anchor(
656    client: &Client,
657    my_pk: &PublicKey,
658    event: &Event,
659) -> (Vec<PackAddress>, Option<String>) {
660    match decrypt_emoji_list_plaintext(client, my_pk, event).await {
661        Some(plaintext) => {
662            let (raw, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
663            let addrs = raw.iter().filter_map(|s| parse_pack_address(s).ok()).collect();
664            (addrs, anchor)
665        }
666        None => (Vec::new(), None),
667    }
668}
669
670// ============================================================================
671// DB persistence
672// ============================================================================
673
674pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
675    let mut conn = crate::db::get_write_connection_guard_static()?;
676    let tx = conn.transaction()
677        .map_err(|e| format!("Failed to start tx: {}", e))?;
678
679    let addr = pack.addr();
680    tx.execute(
681        "INSERT OR REPLACE INTO emoji_packs
682            (addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
683         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
684        rusqlite::params![
685            addr, pack.pubkey, pack.identifier,
686            pack.title, pack.image_url, pack.description,
687            pack.is_own as i32, pack.updated_at as i64,
688        ],
689    ).map_err(|e| format!("Failed to upsert pack: {}", e))?;
690
691    // Replace the item set wholesale — kind 30030 is a replaceable event,
692    // older shortcodes that disappeared from the new version must not
693    // linger in our local mirror.
694    tx.execute(
695        "DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
696        rusqlite::params![addr],
697    ).map_err(|e| format!("Failed to clear pack items: {}", e))?;
698
699    for (pos, emoji) in pack.emojis.iter().enumerate() {
700        tx.execute(
701            "INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
702             VALUES (?1, ?2, ?3, ?4, ?5)",
703            rusqlite::params![
704                addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
705            ],
706        ).map_err(|e| format!("Failed to insert pack item: {}", e))?;
707    }
708
709    tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
710    Ok(())
711}
712
713/// Apply one refresh sweep's verdict to a pack's persisted health. Returns
714/// `true` when the pack's STATUS changed (caller emits a UI refresh).
715///
716/// Rules:
717/// - `Found` resets everything to active — self-healing from any state, even
718///   revoked (the author republished, or a relay restored from backup).
719/// - `Tombstoned` is deterministic: revoked immediately, no gauntlet.
720/// - `CleanMiss` runs the gauntlet: the counter moves at most once per
721///   [`PACK_MISS_RATELIMIT_SECS`], and promotion to missing requires BOTH
722///   [`PACK_MISS_PROMOTE_COUNT`] misses and [`PACK_MISS_PROMOTE_SECS`] since
723///   the first — so neither rapid restarts nor one long offline stretch can
724///   false-positive. A revoked pack ignores misses (stronger evidence holds).
725/// - `Unreachable` never moves anything in either direction.
726///
727/// On a transition INTO revoked/missing, the pack's emojis are purged from
728/// the frequently-used engine so dead emojis stop being suggested.
729pub fn apply_pack_health(addr: &str, outcome: &PackFetchOutcome, now: i64) -> Result<bool, String> {
730    use rusqlite::OptionalExtension;
731    if matches!(outcome, PackFetchOutcome::Unreachable) {
732        return Ok(false);
733    }
734    // IMMEDIATE transaction: concurrent sweeps (boot trigger + panel refresh)
735    // each get their own pooled connection, so an unserialized read-modify-write
736    // could let a stale CleanMiss overwrite a just-written tombstone.
737    let mut conn = crate::db::get_write_connection_guard_static()?;
738    let tx = conn
739        .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
740        .map_err(|e| format!("begin pack health tx: {}", e))?;
741
742    let row = tx
743        .query_row(
744            "SELECT status, miss_count, first_missed_at, last_miss_counted_at
745             FROM emoji_packs WHERE addr = ?1",
746            rusqlite::params![addr],
747            |r| {
748                Ok((
749                    r.get::<_, i64>(0)?,
750                    r.get::<_, i64>(1)?,
751                    r.get::<_, i64>(2)?,
752                    r.get::<_, i64>(3)?,
753                ))
754            },
755        )
756        .optional()
757        .map_err(|e| format!("read pack health: {}", e))?;
758    let Some((status, miss_count, first_missed_at, last_miss_counted_at)) = row else {
759        return Ok(false);
760    };
761
762    let (new_status, new_miss, new_first, new_last) = match outcome {
763        PackFetchOutcome::Found => (PACK_STATUS_ACTIVE as i64, 0, 0, 0),
764        PackFetchOutcome::Tombstoned => (PACK_STATUS_REVOKED as i64, 0, 0, 0),
765        PackFetchOutcome::CleanMiss => {
766            if status == PACK_STATUS_REVOKED as i64 {
767                (status, miss_count, first_missed_at, last_miss_counted_at)
768            } else if now - last_miss_counted_at < PACK_MISS_RATELIMIT_SECS {
769                (status, miss_count, first_missed_at, last_miss_counted_at)
770            } else {
771                let first = if miss_count == 0 { now } else { first_missed_at };
772                let count = miss_count + 1;
773                let promoted = count >= PACK_MISS_PROMOTE_COUNT
774                    && now - first >= PACK_MISS_PROMOTE_SECS;
775                (
776                    if promoted { PACK_STATUS_MISSING as i64 } else { status },
777                    count,
778                    first,
779                    now,
780                )
781            }
782        }
783        PackFetchOutcome::Unreachable => unreachable!(),
784    };
785
786    let changed = new_status != status;
787    tx.execute(
788        "UPDATE emoji_packs SET status = ?2, miss_count = ?3, first_missed_at = ?4,
789             last_miss_counted_at = ?5,
790             status_changed_at = CASE WHEN status != ?2 THEN ?6 ELSE status_changed_at END
791         WHERE addr = ?1",
792        rusqlite::params![addr, new_status, new_miss, new_first, new_last, now],
793    )
794    .map_err(|e| format!("write pack health: {}", e))?;
795
796    // Dead pack: retire its emojis from the frequently-used engine (kind 1 =
797    // custom emoji rows, matched by image URL — shortcodes collide across packs).
798    if changed && new_status != PACK_STATUS_ACTIVE as i64 {
799        tx.execute(
800            "DELETE FROM emoji_usage WHERE kind = 1 AND url IN
801                 (SELECT url FROM emoji_pack_items WHERE pack_addr = ?1)",
802            rusqlite::params![addr],
803        )
804        .map_err(|e| format!("purge dead pack usage: {}", e))?;
805    }
806
807    tx.commit().map_err(|e| format!("commit pack health: {}", e))?;
808    Ok(changed)
809}
810
811pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
812    let mut conn = crate::db::get_write_connection_guard_static()?;
813    let tx = conn.transaction()
814        .map_err(|e| format!("Failed to start tx: {}", e))?;
815
816    tx.execute("DELETE FROM emoji_pack_subscriptions", [])
817        .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
818
819    let now = std::time::SystemTime::now()
820        .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
821    // `position` (the slice index) is the authoritative display order — the
822    // DELETE-all-reinsert stamps every row with the same `now`, so ordering
823    // by `subscribed_at` alone would be unstable.
824    for (pos, addr) in addrs.iter().enumerate() {
825        tx.execute(
826            "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at, position)
827             VALUES (?1, ?2, ?3)",
828            rusqlite::params![addr, now, pos as i64],
829        ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
830    }
831
832    tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
833    Ok(())
834}
835
836pub fn load_subscriptions() -> Result<Vec<String>, String> {
837    let conn = crate::db::get_db_connection_guard_static()?;
838    let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY position ASC, subscribed_at ASC")
839        .map_err(|e| format!("prepare: {}", e))?;
840    let rows = stmt.query_map([], |row| row.get::<_, String>(0))
841        .map_err(|e| format!("query: {}", e))?;
842    let mut out = Vec::new();
843    for r in rows {
844        out.push(r.map_err(|e| format!("row: {}", e))?);
845    }
846    Ok(out)
847}
848
849/// Load a single cached pack by its raw `kind:pubkey:identifier` addr,
850/// regardless of subscription status. Used by the theme-pack path: a theme
851/// pack is persisted via `save_pack` (so it loads instantly across sessions)
852/// but never gets a subscription row, so `load_all_packs` rightly hides it.
853pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
854    let conn = crate::db::get_db_connection_guard_static()?;
855
856    let mut pack = match conn.query_row(
857        "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at, status
858         FROM emoji_packs WHERE addr = ?1",
859        rusqlite::params![addr],
860        |row| {
861            Ok(EmojiPack {
862                id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
863                pubkey: row.get(0)?,
864                identifier: row.get(1)?,
865                title: row.get(2)?,
866                image_url: row.get(3)?,
867                description: row.get(4)?,
868                is_own: row.get::<_, i32>(5)? != 0,
869                updated_at: row.get::<_, i64>(6)? as u64,
870                emojis: Vec::new(),
871                status: row.get::<_, i64>(7)? as u8,
872            })
873        },
874    ) {
875        Ok(p) => p,
876        Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
877        Err(e) => return Err(format!("query cached pack: {}", e)),
878    };
879
880    let mut stmt = conn.prepare(
881        "SELECT shortcode, url, sha256 FROM emoji_pack_items
882         WHERE pack_addr = ?1 ORDER BY position ASC"
883    ).map_err(|e| format!("prepare items: {}", e))?;
884    let rows = stmt.query_map(rusqlite::params![addr], |row| {
885        Ok(PackEmoji {
886            shortcode: row.get(0)?,
887            url: row.get(1)?,
888            sha256: row.get(2)?,
889        })
890    }).map_err(|e| format!("query items: {}", e))?;
891    for r in rows {
892        pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
893    }
894
895    Ok(Some(pack))
896}
897
898/// Load every locally-cached pack the user is currently subscribed to
899/// (plus their own packs, which always count). Hydrated with items.
900/// Cached non-subscribed pack rows stay in the DB so historic reactions
901/// still resolve their image URLs — they're just hidden from the picker.
902pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
903    let conn = crate::db::get_db_connection_guard_static()?;
904
905    let mut packs: HashMap<String, EmojiPack> = HashMap::new();
906    let mut order: Vec<String> = Vec::new();
907
908    {
909        // INNER JOIN — only show subscribed packs. Own packs are
910        // auto-subscribed when published (see `publish_pack`), so this
911        // surfaces them by default; if the user explicitly unsubscribes
912        // their own pack via the right-click "Remove" path, it drops out
913        // of the picker but stays on Nostr and in `emoji_packs` so a
914        // later re-subscribe (paste naddr) restores it with `is_own` set.
915        let mut stmt = conn.prepare(
916            "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at, p.status
917             FROM emoji_packs p
918             INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
919             ORDER BY s.position ASC"
920        ).map_err(|e| format!("prepare packs: {}", e))?;
921
922        let rows = stmt.query_map([], |row| {
923            // Row col 0 is the raw addr (kind:pubkey:identifier). Encode
924            // to naddr here so the public `id` field is consistent with
925            // what `parse_pack_from_event` produces.
926            let raw_addr: String = row.get(0)?;
927            let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
928            Ok((raw_addr, EmojiPack {
929                id,
930                pubkey: row.get(1)?,
931                identifier: row.get(2)?,
932                title: row.get(3)?,
933                image_url: row.get(4)?,
934                description: row.get(5)?,
935                is_own: row.get::<_, i32>(6)? != 0,
936                updated_at: row.get::<_, i64>(7)? as u64,
937                emojis: Vec::new(),
938                status: row.get::<_, i64>(8)? as u8,
939            }))
940        }).map_err(|e| format!("query packs: {}", e))?;
941
942        for r in rows {
943            let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
944            order.push(raw_addr.clone());
945            packs.insert(raw_addr, pack);
946        }
947    }
948
949    {
950        let mut stmt = conn.prepare(
951            "SELECT pack_addr, shortcode, url, sha256
952             FROM emoji_pack_items
953             ORDER BY pack_addr, position ASC"
954        ).map_err(|e| format!("prepare items: {}", e))?;
955
956        let rows = stmt.query_map([], |row| {
957            Ok((
958                row.get::<_, String>(0)?,
959                PackEmoji {
960                    shortcode: row.get(1)?,
961                    url: row.get(2)?,
962                    sha256: row.get(3)?,
963                },
964            ))
965        }).map_err(|e| format!("query items: {}", e))?;
966
967        for r in rows {
968            let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
969            if let Some(p) = packs.get_mut(&addr) {
970                p.emojis.push(emoji);
971            }
972        }
973    }
974
975    Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
976}
977
978// ============================================================================
979// Author outbox (NIP-65)
980// ============================================================================
981
982/// How long a cached author relay list stays valid before re-fetching.
983/// NIP-65 lists rarely change (relay-set edits are a deliberate user act);
984/// an hour amortises the overhead without serving routing that's egregiously
985/// stale. Mirrors the TTL the NIP-17 inbox cache uses.
986const NIP65_CACHE_TTL_SECS: u64 = 3600;
987/// Shorter TTL after an empty/failed lookup so a transient relay blip doesn't
988/// suppress outbox routing for a whole hour.
989const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
990const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
991
992#[derive(Clone)]
993struct CachedRelayList {
994    relays: Vec<RelayUrl>,
995    fetched_at: std::time::Instant,
996    /// Empty fetches use the shorter TTL so transient outages recover fast.
997    empty: bool,
998    /// True when the entry came from a SUCCESSFUL kind-10002 fetch. An
999    /// error-cached placeholder (kept so previews don't refetch-storm) must
1000    /// never read as "author verifiably has no relays" to the health sweep.
1001    verified: bool,
1002}
1003
1004/// Pack-author relay lists. Author-keyed, so technically account-agnostic, but
1005/// the set of authors is contact-graph metadata belonging to one account.
1006struct Nip65Cache;
1007
1008fn nip65_cache() -> std::sync::Arc<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> {
1009    crate::db::current_session().scoped::<Nip65Cache, _>()
1010}
1011
1012/// Read fresh write relays for `pubkey` from the cache, or `None` if absent /
1013/// expired. Honours the dual TTL (short for empty entries).
1014fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1015    let owner = nip65_cache();
1016    let cache = owner.read().ok()?;
1017    let entry = cache.get(pubkey)?;
1018    let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1019    if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1020        Some(entry.relays.clone())
1021    } else {
1022        None
1023    }
1024}
1025
1026/// Like [`cached_write_relays`], but only entries learned from a SUCCESSFUL
1027/// kind-10002 fetch. Judging a pack's absence needs its canonical home to
1028/// have really been resolved; an error placeholder must read as unknown.
1029fn cached_write_relays_verified(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1030    let owner = nip65_cache();
1031    let cache = owner.read().ok()?;
1032    let entry = cache.get(pubkey)?;
1033    if !entry.verified {
1034        return None;
1035    }
1036    let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1037    if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1038        Some(entry.relays.clone())
1039    } else {
1040        None
1041    }
1042}
1043
1044/// Store a freshly-resolved write-relay list for `pubkey` in the cache.
1045fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>, verified: bool) {
1046    if let Ok(mut cache) = nip65_cache().write() {
1047        let empty = relays.is_empty();
1048        cache.insert(pubkey, CachedRelayList {
1049            relays,
1050            fetched_at: std::time::Instant::now(),
1051            empty,
1052            verified,
1053        });
1054    }
1055}
1056
1057/// Extract the write relays from a kind-10002 event. NIP-65: marker absent =
1058/// read+write (both), "write" = author publishes here, "read"-only = consumes
1059/// only (useless for finding their packs), so we keep both/write and drop read.
1060fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
1061    let mut relays: Vec<RelayUrl> = Vec::new();
1062    for (url, marker) in nostr_sdk::prelude::nip65::extract_relay_list(ev) {
1063        match marker {
1064            None | Some(nostr_sdk::prelude::nip65::RelayMetadata::Write) => {
1065                if !relays.contains(&url) {
1066                    relays.push(url.clone());
1067                }
1068            }
1069            Some(nostr_sdk::prelude::nip65::RelayMetadata::Read) => {}
1070        }
1071    }
1072    relays
1073}
1074
1075/// Resolve the author's NIP-65 (kind-10002) write relays — where they publish.
1076/// Returns an empty Vec on absence or fetch error; callers must treat absence
1077/// as "no extra hints," not a failure. Cached per-pubkey. Used by the
1078/// single-pack path; the batched list path uses `prefetch_author_write_relays`.
1079async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
1080    if let Some(relays) = cached_write_relays(&pubkey) {
1081        return relays;
1082    }
1083
1084    let filter = Filter::new()
1085        .author(pubkey)
1086        .kind(Kind::RelayList)
1087        .limit(1);
1088    let events = match client
1089        .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1090        .await
1091    {
1092        Ok(evs) => evs,
1093        Err(_) => {
1094            cache_write_relays(pubkey, Vec::new(), false);
1095            return Vec::new();
1096        }
1097    };
1098
1099    let relays = events.into_iter()
1100        .max_by_key(|e| e.created_at)
1101        .map(|ev| extract_write_relays(&ev))
1102        .unwrap_or_default();
1103    cache_write_relays(pubkey, relays.clone(), true);
1104    relays
1105}
1106
1107/// Warm the NIP-65 cache for many authors in ONE request. Used by the batched
1108/// subscribed-list path so a boot with N federated packs pays a single
1109/// kind-10002 fetch instead of N. Authors already cached-fresh are skipped;
1110/// authors with no kind-10002 are cached empty (short TTL) so we don't refetch
1111/// them every pass.
1112async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
1113    let uncached: Vec<PublicKey> = authors.iter()
1114        .filter(|pk| cached_write_relays(pk).is_none())
1115        .copied()
1116        .collect();
1117    if uncached.is_empty() {
1118        return;
1119    }
1120
1121    let filter = Filter::new()
1122        .authors(uncached.iter().copied())
1123        .kind(Kind::RelayList);
1124    let events = match client
1125        .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1126        .await
1127    {
1128        Ok(evs) => evs,
1129        // On error, leave the cache cold — the next pass retries rather than
1130        // poisoning every author with an empty entry off one failed batch.
1131        Err(_) => return,
1132    };
1133
1134    // Keep the newest kind-10002 per author, then cache each.
1135    let mut newest: HashMap<PublicKey, Event> = HashMap::new();
1136    for ev in events {
1137        match newest.get(&ev.pubkey) {
1138            Some(existing) if existing.created_at >= ev.created_at => {}
1139            _ => { newest.insert(ev.pubkey, ev); }
1140        }
1141    }
1142    for pk in uncached {
1143        let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
1144        cache_write_relays(pk, relays, true);
1145    }
1146}
1147
1148// ============================================================================
1149// Relay fetch
1150// ============================================================================
1151
1152/// Resolve a single pack from relays by its parsed address. Returns
1153/// `None` when no matching event is found within the timeout — callers
1154/// distinguish "unknown" from "fetch error" by the caller's own error
1155/// pathway (every relay call here that errors logs and proceeds).
1156async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
1157    let filter = Filter::new()
1158        .author(addr.pubkey)
1159        .kind(Kind::Custom(KIND_EMOJI_SET))
1160        .identifier(&addr.identifier)
1161        .limit(1);
1162    let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1163    let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1164
1165    // 1) Home relays first (the shared pool). Covers our own packs and any
1166    //    pack that's on Vector's default relays — the common, fast case.
1167    match client.fetch_events(filter.clone()).timeout(timeout).await {
1168        Ok(events) => {
1169            if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
1170                if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
1171                    return Some(pack);
1172                }
1173            }
1174        }
1175        Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
1176    }
1177
1178    // 2) Outbox fallback (NIP-65): the pack lives wherever the creator
1179    //    publishes, which may sit outside our relays. Fetch through an
1180    //    ISOLATED throwaway client so these third-party relays never enter
1181    //    the shared pool — the DM/community sync loops enumerate that pool and
1182    //    would otherwise reconcile against every pack author's relays.
1183    let outbox = fetch_author_write_relays(client, addr.pubkey).await;
1184    if outbox.is_empty() {
1185        return None;
1186    }
1187    fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
1188}
1189
1190/// Fetch a kind-30030 pack through a dedicated, short-lived client connected
1191/// only to the given relays. Built with Tor-aware options; fully torn down
1192/// before returning so nothing leaks into the app's relay pool or sync loops.
1193async fn fetch_pack_via_isolated_client(
1194    relays: &[RelayUrl],
1195    filter: Filter,
1196    timeout: std::time::Duration,
1197    my_pubkey_hex: Option<&str>,
1198) -> Option<EmojiPack> {
1199    let scratch = crate::nostr_client_builder()
1200        .build();
1201    for r in relays {
1202        let _ = scratch.add_managed_relay(r.as_str()).await;
1203    }
1204    scratch.connect().await;
1205
1206    let result = scratch.fetch_events(filter).timeout(timeout).await;
1207    // Tear the scratch client down regardless of outcome.
1208    scratch.shutdown().await;
1209
1210    let events = match result {
1211        Ok(events) => events,
1212        Err(e) => {
1213            crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
1214            return None;
1215        }
1216    };
1217    let event = events.into_iter().max_by_key(|e| e.created_at)?;
1218    parse_pack_from_event(&event, my_pubkey_hex)
1219}
1220
1221// ============================================================================
1222// Batched relay fetch (subscribed-list path ONLY)
1223// ============================================================================
1224//
1225// `fetch_pack_from_relays` (above) resolves ONE pack and is used by the
1226// per-pack flows: in-chat preview cards and the pinned theme pack, which
1227// arrive as independent render events and must stay independent.
1228//
1229// `fetch_packs_from_relays` (below) resolves MANY packs whose coordinates are
1230// all known up front — i.e. the user's own subscribed list. It collapses what
1231// used to be N requests into one batched home request plus, for any packs not
1232// on our relays, one batched NIP-65 prefetch and one batched outbox request.
1233// These two paths intentionally do NOT share fetch logic: the single path is
1234// kept byte-stable so the preview/theme behaviour can't regress.
1235
1236/// Coordinate key for matching a kind-30030 event back to a requested pack:
1237/// `pubkey_hex:identifier`. (Not the `30030:`-prefixed addr — just the parts a
1238/// fetched event exposes via its author + `d` tag.)
1239fn event_coord(ev: &Event) -> Option<String> {
1240    let d = first_tag(&ev.tags, &["d"])?;
1241    Some(format!("{}:{}", ev.pubkey.to_hex(), d))
1242}
1243fn addr_coord(addr: &PackAddress) -> String {
1244    format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
1245}
1246
1247/// One batched filter matches the cross-product of authors × identifiers, so it
1248/// can return events we didn't ask for (author A's `d` that belongs to author
1249/// B's pack). Match strictly by exact coordinate and keep the newest event per
1250/// coordinate; strays are dropped. Returns raw EVENTS (not parsed packs): an
1251/// empty kind 30030 is a deletion tombstone, and parsing would erase exactly
1252/// that evidence.
1253fn newest_events_by_coord(
1254    events: impl IntoIterator<Item = Event>,
1255    wanted: &std::collections::HashSet<String>,
1256) -> HashMap<String, Event> {
1257    let mut newest: HashMap<String, Event> = HashMap::new();
1258    for ev in events {
1259        if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
1260        let Some(coord) = event_coord(&ev) else { continue; };
1261        if !wanted.contains(&coord) { continue; }
1262        match newest.get(&coord) {
1263            Some(existing) if existing.created_at >= ev.created_at => {}
1264            _ => { newest.insert(coord, ev); }
1265        }
1266    }
1267    newest
1268}
1269
1270/// Merge a phase's newest-events into the accumulator, keeping the newer of
1271/// the two per coordinate.
1272fn merge_newest(acc: &mut HashMap<String, Event>, phase: HashMap<String, Event>) {
1273    for (coord, ev) in phase {
1274        match acc.get(&coord) {
1275            Some(existing) if existing.created_at >= ev.created_at => {}
1276            _ => { acc.insert(coord, ev); }
1277        }
1278    }
1279}
1280
1281/// Batched NIP-09 deletion filter for the given packs: author-signed kind 5s
1282/// that cite a pack coordinate in an `a` tag.
1283fn deletion_filter(addrs: &[&PackAddress]) -> Filter {
1284    Filter::new()
1285        .authors(addrs.iter().map(|a| a.pubkey))
1286        .kind(Kind::EventDeletion)
1287        .custom_tags(
1288            SingleLetterTag::LOWERCASE_A,
1289            addrs.iter().map(|a| a.to_addr_string()),
1290        )
1291}
1292
1293/// Does this kind-5, signed by the pack's author, cite the pack's coordinate?
1294/// Third-party deletions never count — only the author may revoke.
1295fn deletion_matches(ev: &Event, author: &PublicKey, raw_addr: &str) -> bool {
1296    ev.kind == Kind::EventDeletion
1297        && ev.pubkey == *author
1298        && ev.tags.iter().any(|t| {
1299            let s = t.as_slice();
1300            s.len() >= 2 && s[0] == "a" && s[1] == raw_addr
1301        })
1302}
1303
1304/// URLs of relays that are CONNECTED and readable right now — the basis for
1305/// judging whether an absence was observed against live relays or thin air.
1306/// READ-flagged only for the main pool (fetches never touch write-only or
1307/// GOSSIP-isolated community relays, so those must not inflate the count).
1308async fn connected_read_relays(client: &Client, read_only: bool) -> std::collections::HashSet<String> {
1309    client
1310        .relays()
1311        .await
1312        .iter()
1313        .filter(|(_, r)| r.status() == RelayStatus::Connected && (!read_only || r.capabilities().load().can_read()))
1314        .map(|(url, _)| url.to_string())
1315        .collect()
1316}
1317
1318/// Is a pack's ABSENCE from this sweep judgeable as a clean miss? Pure so the
1319/// gate combinations are table-testable.
1320///
1321/// - The home fetch must have completed against relays that were connected
1322///   both before AND after it ran (`connect()` is non-blocking, so an
1323///   after-only sample would bless a fetch that raced an empty pool).
1324/// - At least two distinct live relays across the phases.
1325/// - The author's NIP-65 write relays are the pack's canonical home:
1326///   unknown (cache cold / fetch failed) means it was never really checked —
1327///   not clean. Verifiably EMPTY means home evidence alone is the best
1328///   anyone can do. Known means the outbox phase must have completed there.
1329fn absence_is_clean(
1330    home_ok: bool,
1331    live_relays: usize,
1332    author_writes: Option<&Vec<RelayUrl>>,
1333    outbox_ok: bool,
1334    outbox_live: usize,
1335) -> bool {
1336    if !home_ok || live_relays < 2 {
1337        return false;
1338    }
1339    match author_writes {
1340        None => false,
1341        Some(w) if w.is_empty() => true,
1342        Some(_) => outbox_ok && outbox_live >= 1,
1343    }
1344}
1345
1346/// Classify one pack from a sweep's evidence. Pure so the verdict table is
1347/// unit-testable. Returns the parsed pack alongside `Found`.
1348fn classify_pack(
1349    newest_ev: Option<&Event>,
1350    newest_deletion: Option<&Event>,
1351    me: Option<&str>,
1352    absence_clean: bool,
1353) -> (PackFetchOutcome, Option<EmojiPack>) {
1354    match newest_ev {
1355        Some(ev) => match parse_pack_from_event(ev, me) {
1356            Some(pack) => {
1357                // A republish NEWER than the deletion revives the pack; on an
1358                // equal timestamp the pack wins (fail-safe toward alive).
1359                match newest_deletion {
1360                    Some(del) if del.created_at > ev.created_at => {
1361                        (PackFetchOutcome::Tombstoned, None)
1362                    }
1363                    _ => (PackFetchOutcome::Found, Some(pack)),
1364                }
1365            }
1366            None => {
1367                // No `emoji` tags at all: the author replaced the pack with an
1368                // empty one — Vector's own delete flow publishes exactly this
1369                // tombstone. An event that HAS emoji tags Vector merely can't
1370                // validate is not a deletion; judge nothing from it.
1371                let has_emoji_tags = ev.tags.iter().any(|t| {
1372                    t.as_slice().first().map(|k| k == "emoji").unwrap_or(false)
1373                });
1374                if has_emoji_tags {
1375                    (PackFetchOutcome::Unreachable, None)
1376                } else {
1377                    (PackFetchOutcome::Tombstoned, None)
1378                }
1379            }
1380        },
1381        None if newest_deletion.is_some() => (PackFetchOutcome::Tombstoned, None),
1382        None if absence_clean => (PackFetchOutcome::CleanMiss, None),
1383        None => (PackFetchOutcome::Unreachable, None),
1384    }
1385}
1386
1387/// Everything one refresh sweep learned: resolved packs (in `addrs` order)
1388/// plus a per-addr verdict for the health engine.
1389struct PackSweep {
1390    packs: Vec<EmojiPack>,
1391    /// raw `kind:pubkey:identifier` addr → verdict.
1392    outcomes: HashMap<String, PackFetchOutcome>,
1393}
1394
1395/// Resolve MANY packs in a batch and judge the ones that didn't resolve.
1396/// Home relays in one request; any unresolved packs then get one batched
1397/// NIP-65 prefetch + one batched outbox request via an isolated client, with
1398/// author-signed kind-5 deletions fetched alongside each phase. Callers keep
1399/// cached copies for anything that isn't `Found`.
1400async fn sweep_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> PackSweep {
1401    if addrs.is_empty() {
1402        return PackSweep { packs: Vec::new(), outcomes: HashMap::new() };
1403    }
1404    let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1405    let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1406    let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1407    let all_refs: Vec<&PackAddress> = addrs.iter().collect();
1408
1409    // 1) One batched home request for every subscribed pack (+ deletions).
1410    // Live-relay evidence is the INTERSECTION of connected READ relays before
1411    // and after the fetch: `connect()` is non-blocking, so an after-only
1412    // sample would bless a fetch that actually ran against an empty pool
1413    // (boot, Tor cold start) — the exact false-positive the gauntlet must
1414    // never produce.
1415    let home_before = connected_read_relays(client, true).await;
1416    let home_filter = Filter::new()
1417        .authors(addrs.iter().map(|a| a.pubkey))
1418        .kind(Kind::Custom(KIND_EMOJI_SET))
1419        .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1420    let mut newest: HashMap<String, Event> = HashMap::new();
1421    let mut deletions: Vec<Event> = Vec::new();
1422    let mut home_ok = false;
1423    match client.fetch_events(home_filter).timeout(timeout).await {
1424        Ok(events) => {
1425            home_ok = true;
1426            merge_newest(&mut newest, newest_events_by_coord(events, &wanted));
1427        }
1428        Err(e) => {
1429            crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1430        }
1431    }
1432    if home_ok {
1433        match client.fetch_events(deletion_filter(&all_refs)).timeout(timeout).await {
1434            Ok(events) => deletions.extend(events),
1435            Err(e) => crate::log_warn!("[EmojiPacks] home deletion fetch failed: {}", e),
1436        }
1437    }
1438    let home_after = connected_read_relays(client, true).await;
1439    let mut live_relays: std::collections::HashSet<String> =
1440        home_before.intersection(&home_after).cloned().collect();
1441
1442    // 2) Outbox fallback for the misses, all in one shot. A pack's canonical
1443    // home is its author's NIP-65 write relays, so absence THERE is the
1444    // signal that matters for the health verdict.
1445    let misses: Vec<&PackAddress> = addrs.iter()
1446        .filter(|a| !newest.contains_key(&addr_coord(a)))
1447        .collect();
1448    let mut outbox_ok = false;
1449    let mut outbox_live = 0usize;
1450    if !misses.is_empty() {
1451        let miss_authors: Vec<PublicKey> = {
1452            let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1453            v.sort(); v.dedup();
1454            v
1455        };
1456        // Warm NIP-65 for all missed authors in one request, then union their
1457        // write relays into a single isolated client + one batched request.
1458        prefetch_author_write_relays(client, &miss_authors).await;
1459        let mut outbox: Vec<RelayUrl> = Vec::new();
1460        for pk in &miss_authors {
1461            for r in cached_write_relays(pk).unwrap_or_default() {
1462                if !outbox.contains(&r) { outbox.push(r); }
1463            }
1464        }
1465        if !outbox.is_empty() {
1466            let miss_filter = Filter::new()
1467                .authors(misses.iter().map(|a| a.pubkey))
1468                .kind(Kind::Custom(KIND_EMOJI_SET))
1469                .identifiers(misses.iter().map(|a| a.identifier.clone()));
1470            let wanted_misses: std::collections::HashSet<String> =
1471                misses.iter().map(|a| addr_coord(a)).collect();
1472            if let Some((events, dels, live)) = sweep_via_isolated_client(
1473                &outbox, miss_filter, deletion_filter(&misses), timeout,
1474            ).await {
1475                outbox_ok = true;
1476                outbox_live = live.len();
1477                live_relays.extend(live);
1478                merge_newest(&mut newest, newest_events_by_coord(events, &wanted_misses));
1479                deletions.extend(dels);
1480            }
1481        }
1482    }
1483
1484    // 3) Classify every pack (pure helpers; see their docs for the rules).
1485    let mut packs: Vec<EmojiPack> = Vec::new();
1486    let mut outcomes: HashMap<String, PackFetchOutcome> = HashMap::new();
1487    for addr in addrs {
1488        let coord = addr_coord(addr);
1489        let raw_addr = addr.to_addr_string();
1490        let newest_deletion = deletions.iter()
1491            .filter(|d| deletion_matches(d, &addr.pubkey, &raw_addr))
1492            .max_by_key(|d| d.created_at);
1493        let author_writes = cached_write_relays_verified(&addr.pubkey);
1494        let clean = absence_is_clean(
1495            home_ok,
1496            live_relays.len(),
1497            author_writes.as_ref(),
1498            outbox_ok,
1499            outbox_live,
1500        );
1501        let (outcome, pack) = classify_pack(
1502            newest.get(&coord),
1503            newest_deletion,
1504            me.as_deref(),
1505            clean,
1506        );
1507        if let Some(p) = pack {
1508            packs.push(p);
1509        }
1510        outcomes.insert(raw_addr, outcome);
1511    }
1512
1513    PackSweep { packs, outcomes }
1514}
1515
1516/// Batched sibling of `fetch_pack_via_isolated_client`: fetch pack events and
1517/// their kind-5 deletions from a throwaway client connected to the given
1518/// relays. Returns `None` when the pack fetch itself errored (the sweep must
1519/// not judge absences it never observed), plus the URLs of relays that were
1520/// connected across the fetch (before ∩ after — `connect()` is non-blocking,
1521/// so a fetch can race an empty pool) for the connectivity gate.
1522async fn sweep_via_isolated_client(
1523    relays: &[RelayUrl],
1524    pack_filter: Filter,
1525    del_filter: Filter,
1526    timeout: std::time::Duration,
1527) -> Option<(Vec<Event>, Vec<Event>, std::collections::HashSet<String>)> {
1528    let scratch = crate::nostr_client_builder()
1529        .build();
1530    for r in relays {
1531        let _ = scratch.add_managed_relay(r.as_str()).await;
1532    }
1533    scratch.connect().await;
1534    // `connect()` is non-blocking and this client is brand new, so wait for at
1535    // least one handshake to complete (bounded) before sampling connectivity —
1536    // an instant sample reads empty on every run, which would make before ∩
1537    // after a tautological zero and outbox absences permanently unjudgeable.
1538    let connect_deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
1539    let before = loop {
1540        let connected = connected_read_relays(&scratch, false).await;
1541        if !connected.is_empty() || std::time::Instant::now() >= connect_deadline {
1542            break connected;
1543        }
1544        tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1545    };
1546    let result = scratch.fetch_events(pack_filter).timeout(timeout).await;
1547    let dels = match &result {
1548        Ok(_) => scratch.fetch_events(del_filter).timeout(timeout).await.ok(),
1549        Err(_) => None,
1550    };
1551    let after = connected_read_relays(&scratch, false).await;
1552    scratch.shutdown().await;
1553
1554    match result {
1555        Ok(events) => Some((
1556            events.into_iter().collect(),
1557            dels.map(|d| d.into_iter().collect()).unwrap_or_default(),
1558            before.intersection(&after).cloned().collect(),
1559        )),
1560        Err(e) => {
1561            crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1562            None
1563        }
1564    }
1565}
1566
1567/// Fetch the user's kind 10030 list, resolve every referenced pack, and
1568/// persist the result locally. Session-guarded against an account swap
1569/// landing the new account's pack list in account A's DB.
1570///
1571/// Non-destructive: a missing kind 10030 event or a transient per-pack
1572/// fetch failure must NOT nuke the user's local subscription list — that
1573/// would wipe their picker on every relay blip. Cached pack data is the
1574/// fallback whenever a fresh fetch fails.
1575pub async fn fetch_subscribed_packs(
1576    client: &Client,
1577    my_pubkey: PublicKey,
1578) -> Result<Vec<EmojiPack>, String> {
1579    crate::db::scoped(async move {
1580        let list_filter = Filter::new()
1581            .author(my_pubkey)
1582            .kind(Kind::Custom(KIND_EMOJI_LIST))
1583            .limit(1);
1584
1585        let list_events = client
1586            .fetch_events(list_filter).timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1587            .await
1588            .map_err(|e| format!("fetch kind 10030: {}", e))?;
1589
1590
1591        // Source-of-truth selection. If relays returned a kind 10030, trust
1592        // its `a` tags as the canonical subscription set — UNLESS it predates
1593        // our own last publish, which means our latest republish hasn't
1594        // propagated yet and the relay is still serving a stale list. Trusting
1595        // a stale list would clobber a just-added pack (last-write-wins by
1596        // created_at). If relays returned *nothing*, that's a transient sync
1597        // gap — fall back to the local mirror either way.
1598        let local_addrs = || -> Vec<PackAddress> {
1599            load_subscriptions()
1600                .unwrap_or_default()
1601                .into_iter()
1602                .filter_map(|s| parse_pack_address(&s).ok())
1603                .collect()
1604        };
1605        let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1606            .ok()
1607            .flatten()
1608            .and_then(|s| s.parse().ok())
1609            .unwrap_or(0);
1610
1611        let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1612        // Anchor only rides in from a TRUSTED relay list — a stale/absent event
1613        // falls back to local subs and leaves the local KV anchor untouched.
1614        let mut fetched_anchor: Option<String> = None;
1615        let addrs: Vec<PackAddress> = match list_event {
1616            Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1617                crate::log_debug!(
1618                    "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1619                    ev.created_at.as_secs(), our_last_publish,
1620                );
1621                local_addrs()
1622            }
1623            Some(ev) => {
1624                let (a, anchor) = decrypt_subscribed_addresses_with_anchor(client, &my_pubkey, &ev).await;
1625                fetched_anchor = anchor;
1626                a
1627            }
1628            None => {
1629                crate::log_debug!(
1630                    "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1631                );
1632                local_addrs()
1633            }
1634        };
1635
1636        let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1637
1638        // Batched resolve: one home request for every subscribed pack, plus one
1639        // batched outbox pass for any not on our relays. Packs that still don't
1640        // resolve keep their cached copy (we never shrink the subscription set on
1641        // a transient miss); the health engine below decides whether an absence
1642        // is judgeable at all. The per-pack `fetch_pack_from_relays` is reserved
1643        // for the independent preview/theme flows.
1644        let PackSweep { packs: fresh, outcomes } = sweep_packs_from_relays(client, &addrs).await;
1645        let tombstoned = outcomes.values().filter(|o| matches!(o, PackFetchOutcome::Tombstoned)).count();
1646        let unresolved = addrs.len().saturating_sub(fresh.len()).saturating_sub(tombstoned);
1647        if unresolved > 0 {
1648            crate::log_warn!(
1649                "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1650                unresolved,
1651            );
1652        }
1653        if tombstoned > 0 {
1654            crate::log_info!("[EmojiPacks] {} subscribed pack(s) deleted by their creator", tombstoned);
1655        }
1656
1657
1658        // Health verdicts BEFORE the save loop: save_pack's REPLACE resets the
1659        // health columns, so `Found` must read the pre-save status or a revival
1660        // (revoked back to active) would go unreported. Own packs are skipped:
1661        // deleting an own pack removes its rows locally, so there's nothing to
1662        // grieve, and a self-authored pack must never grey out over relay state.
1663        let me_hex = my_pubkey.to_hex();
1664        let now = std::time::SystemTime::now()
1665            .duration_since(std::time::UNIX_EPOCH)
1666            .map(|d| d.as_secs() as i64)
1667            .unwrap_or(0);
1668        let mut health_changed = false;
1669        for addr in &addrs {
1670            if addr.pubkey.to_hex() == me_hex { continue; }
1671            let raw = addr.to_addr_string();
1672            let Some(outcome) = outcomes.get(&raw) else { continue; };
1673            match apply_pack_health(&raw, outcome, now) {
1674                Ok(true) => {
1675                    health_changed = true;
1676                    crate::log_info!(
1677                        "[EmojiPacks] pack `{}` health changed ({:?})",
1678                        addr.identifier, outcome,
1679                    );
1680                }
1681                Ok(false) => {}
1682                Err(e) => crate::log_warn!("[EmojiPacks] health update for `{}` failed: {}", addr.identifier, e),
1683            }
1684        }
1685        for pack in &fresh {
1686            if let Err(e) = save_pack(pack) {
1687                crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1688            }
1689        }
1690        // Detect a real list change (reorder, sub add/remove, or theme-slot move)
1691        // against the pre-save state, so a cross-device edit repaints an OPEN
1692        // picker live — not only on its next open. Our own republish echoes back
1693        // unchanged, so this stays quiet on self-echo.
1694        let list_changed = load_subscriptions().unwrap_or_default() != addr_strings
1695            || fetched_anchor.as_deref().map_or(false, |a| a != load_theme_slot_anchor());
1696
1697        // Persist the full subscription list (10030-driven, or local-mirror
1698        // when 10030 was missing). Per-pack fetch failures don't shrink it —
1699        // the user is still subscribed, they just have a cached copy for now.
1700        if let Err(e) = save_subscriptions(&addr_strings) {
1701            crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1702        }
1703        // Sync the theme-slot position from the trusted list (old-format lists
1704        // carry no marker → leave the local anchor as-is).
1705        if let Some(anchor) = fetched_anchor {
1706            if let Err(e) = save_theme_slot_anchor(&anchor) {
1707                crate::log_warn!("[EmojiPacks] save theme slot anchor failed: {}", e);
1708            }
1709        }
1710        if health_changed || list_changed {
1711            crate::traits::emit_event("emoji_packs_updated", &());
1712        }
1713
1714        crate::log_info!(
1715            "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1716            fresh.len(),
1717            addrs.len(),
1718            if unresolved > 0 {
1719                format!(" ({} via cache)", unresolved)
1720            } else {
1721                String::new()
1722            },
1723        );
1724
1725        // Return the unified view: freshly-fetched packs overlay the cached
1726        // ones, and load_all_packs filters to subscribed-only for us.
1727        load_all_packs()
1728    })
1729    .await
1730}
1731
1732/// Convenience entry point that grabs the client + my_pubkey internally
1733/// and runs the full subscribed-packs refresh. Intended for the boot
1734/// path; in-app commands pass an explicit `std::sync::Arc<crate::db::Session>` via the lower
1735/// helper to make the safety contract visible at every call site.
1736pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1737    let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1738    let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1739    fetch_subscribed_packs(&client, me).await
1740}
1741
1742/// Preview-only fetch by naddr — resolves + parses but never touches
1743/// local DB. Lets the UI render a "Pack Preview" card without committing
1744/// to a subscription.
1745pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1746    let addr = parse_naddr(naddr)?;
1747    let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1748    fetch_pack_from_relays(&client, &addr).await
1749        .ok_or_else(|| {
1750            // Coordinate goes to the log; the returned string is shown
1751            // verbatim in the preview card, so keep it human-readable.
1752            crate::log_debug!("[EmojiPacks] preview miss: {}:{}", addr.pubkey.to_hex(), addr.identifier);
1753            "Pack not found on any relay".to_string()
1754        })
1755}
1756
1757/// Resolve a theme pack cache-first: return the locally-persisted copy
1758/// instantly when present (and refresh it in the background), otherwise fetch
1759/// live, persist, and return. Theme packs are pinned by the active theme, not
1760/// subscribed — `save_pack` persists their data without a subscription row, so
1761/// they survive restarts (no per-session relay round-trip) yet never occupy an
1762/// equip slot or land in the kind-10030 list. Returns `None` if uncached and
1763/// the live fetch finds nothing.
1764pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1765    crate::db::scoped(async move {
1766        let addr = parse_naddr(naddr)?;
1767        let coord = addr.to_addr_string();
1768
1769        // Cache hit: return immediately, refresh in the background so a later
1770        // creator-side edit still propagates without blocking first paint.
1771        if let Some(cached) = load_cached_pack(&coord)? {
1772            let naddr_owned = naddr.to_string();
1773            crate::db::spawn_bound(async move {
1774                let Some(client) = nostr_client() else { return };
1775                if let Ok(parsed) = parse_naddr(&naddr_owned) {
1776                    if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1777                        if fresh.updated_at > cached.updated_at {
1778                            if let Err(e) = save_pack(&fresh) {
1779                                crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1780                            } else {
1781                                crate::traits::emit_event("emoji_packs_updated", &());
1782                            }
1783                        }
1784                    }
1785                }
1786            });
1787            return Ok(Some(cached));
1788        }
1789
1790        // Cache miss: fetch live, persist for next session, return.
1791        let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1792        match fetch_pack_from_relays(&client, &addr).await {
1793            Some(pack) => {
1794                // Still show the pack this session, but only persist if the account
1795                // didn't swap during the fetch — otherwise we'd write into the wrong
1796                // account's DB.
1797                if let Err(e) = save_pack(&pack) {
1798                    crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1799                }
1800                Ok(Some(pack))
1801            }
1802            None => Ok(None),
1803        }
1804    })
1805    .await
1806}
1807
1808/// KV setting holding the raw addr (`30030:pk:d`) of the pack the theme slot
1809/// sits immediately AFTER. Empty string = slot at the top (first); a never-set
1810/// key defaults to `""`, matching the historic "theme pinned first" behaviour.
1811/// Kept OUT of `emoji_pack_subscriptions` so the DELETE-all-reinsert in
1812/// `save_subscriptions` can't wipe it (the theme pack is not a real sub row).
1813const THEME_SLOT_ANCHOR_KEY: &str = "emoji_theme_slot_anchor";
1814
1815/// Read the theme-slot anchor (raw addr), defaulting to `""` (top).
1816fn load_theme_slot_anchor() -> String {
1817    crate::db::settings::get_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string())
1818        .ok()
1819        .flatten()
1820        .unwrap_or_default()
1821}
1822
1823/// Persist the theme-slot anchor (raw addr, or `""` for top).
1824fn save_theme_slot_anchor(raw_addr: &str) -> Result<(), String> {
1825    crate::db::settings::set_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string(), raw_addr.to_string())
1826}
1827
1828/// Build the kind-10030 inner tuple list from the subscription order, splicing
1829/// in exactly one `["theme_slot"]` marker at the anchored position. Empty
1830/// anchor (or an anchor naming a pack no longer subscribed) puts the marker at
1831/// the top. Pure so the interleave is unit-testable.
1832fn build_inner_tags_with_marker(addrs: &[String], anchor: &str) -> Vec<Vec<String>> {
1833    let mut inner: Vec<Vec<String>> = Vec::with_capacity(addrs.len() + 1);
1834    let mut placed = false;
1835    if anchor.is_empty() {
1836        inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1837        placed = true;
1838    }
1839    for addr in addrs {
1840        inner.push(vec!["a".to_string(), addr.clone()]);
1841        if !placed && addr == anchor {
1842            inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1843            placed = true;
1844        }
1845    }
1846    // Anchor named a pack that's gone: fall back to the top so we always emit
1847    // exactly one marker.
1848    if !placed {
1849        inner.insert(0, vec![THEME_SLOT_TOKEN.to_string()]);
1850    }
1851    inner
1852}
1853
1854/// Publish a kind 10030 "Emojis" list containing every subscribed pack.
1855///
1856/// Encrypted-items mode: the entire subscription set lives inside a
1857/// NIP-44-self-encrypted JSON array of `["a", "30030:pk:d"]` tuples
1858/// stored in `content`, plus one `["theme_slot"]` marker recording where the
1859/// theme pack renders. The event's public `tags` field is left empty
1860/// — Vector treats which packs a user follows as private information,
1861/// matching the NIP-51 "encrypted items" pattern that mute lists use.
1862/// Replaceable per spec, so peers (the same npub on another device)
1863/// always read the freshest set on next sync.
1864pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1865    let addrs = load_subscriptions()?;
1866    let anchor = load_theme_slot_anchor();
1867    let my_pk = crate::state::my_public_key()
1868        .ok_or_else(|| "Not logged in".to_string())?;
1869
1870    let inner_tags = build_inner_tags_with_marker(&addrs, &anchor);
1871    let plaintext = serde_json::to_string(&inner_tags)
1872        .map_err(|e| format!("Serialise emoji list: {}", e))?;
1873
1874    let signer = crate::signer::active_signer()
1875        .map_err(|e| format!("Signer unavailable: {}", e))?;
1876    let content = signer.nip44_encrypt_async(&my_pk, &plaintext).await
1877        .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1878
1879    let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1880    crate::sign_and_send(&client, builder).await
1881        .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1882
1883    crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1884    Ok(())
1885}
1886
1887/// Settings key holding the UNIX-seconds timestamp of our most recent local
1888/// subscription mutation. A refresh ignores any relay kind-10030 older than
1889/// this so our just-changed (not-yet-propagated) list can't be clobbered.
1890const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1891
1892static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1893
1894/// Coalesce rapid subscribe/unsubscribe taps into one network publish.
1895/// Captures `std::sync::Arc<crate::db::Session>` BEFORE the spawn boundary so a mid-debounce
1896/// account swap can't sign account A's pack list with account B's key.
1897pub fn republish_emoji_list_debounced() {
1898    use std::sync::atomic::Ordering;
1899    // Stamp the mutation time NOW (synchronously, before the debounce sleep)
1900    // so a refresh racing the not-yet-fired publish still treats the local
1901    // set as newer than any stale relay copy. Every local subscription change
1902    // funnels through here; the refresh-persist path does not, so this can't
1903    // wrongly suppress a legit cross-device update.
1904    let _ = crate::db::settings::set_sql_setting(
1905        EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1906        Timestamp::now().as_secs().to_string(),
1907    );
1908    let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1909    crate::db::spawn_bound(async move {
1910        tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1911        if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1912        let client = match nostr_client() {
1913            Some(c) => c,
1914            None => return,
1915        };
1916        if let Err(e) = publish_emoji_list(&client).await {
1917            crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1918            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1919            if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1920            if let Err(e2) = publish_emoji_list(&client).await {
1921                crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1922            }
1923        }
1924    });
1925}
1926
1927/// Subscribe to a pack by naddr: fetch the pack, persist it + the
1928/// subscription, then schedule a debounced republish of kind 10030.
1929/// Returns the hydrated pack on success.
1930pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1931    crate::db::scoped(async move {
1932        let pack = fetch_pack_by_naddr(naddr).await?;
1933
1934        // Equipped-pack cap. Idempotent re-subscribe to a pack we already
1935        // have stays free; only adding a brand-new addr counts toward the limit.
1936        let pack_addr = pack.addr();
1937        {
1938            let existing_subs = load_subscriptions()?;
1939            let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
1940            let cap = effective_max_equipped_packs();
1941            if is_new && existing_subs.len() >= cap {
1942                return Err(format!(
1943                    "You can equip at most {} packs. Remove one to add another.",
1944                    cap,
1945                ));
1946            }
1947        }
1948
1949        save_pack(&pack)?;
1950
1951        let mut subs = load_subscriptions()?;
1952        if !subs.iter().any(|a| a == &pack_addr) {
1953            subs.push(pack_addr.clone());
1954        }
1955        save_subscriptions(&subs)?;
1956
1957        republish_emoji_list_debounced();
1958        crate::traits::emit_event("emoji_packs_updated", &());
1959
1960        Ok(pack)
1961    })
1962    .await
1963}
1964
1965// ============================================================================
1966// Pack publish (own creator path)
1967// ============================================================================
1968
1969/// Build a kind 30030 EventBuilder for one of the user's own packs.
1970/// Dual-writes the NIP-51 spec tags (`title`/`image`/`description`)
1971/// alongside the Ditto-style (`name`/`picture`/`about`) tags so packs
1972/// interop with both ecosystems — see `MEMORY.md` plan notes.
1973fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1974    if pack.identifier.is_empty() {
1975        return Err("pack identifier required".to_string());
1976    }
1977    let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1978        .tag(Tag::custom("d", [pack.identifier.clone()]))
1979        // Stamp Vector as the authoring client (same ["client","vector"] tag our kind-0
1980        // profile publishes carry) so packs made or edited here are attributable.
1981        .tag(Tag::custom("client", ["vector"]));
1982
1983    // Spec-compliant metadata (NIP-51).
1984    if !pack.title.is_empty() {
1985        builder = builder
1986            .tag(Tag::custom("title", [pack.title.clone()]))
1987            .tag(Tag::custom("name", [pack.title.clone()]));
1988    }
1989    if !pack.image_url.is_empty() {
1990        builder = builder
1991            .tag(Tag::custom("image", [pack.image_url.clone()]))
1992            .tag(Tag::custom("picture", [pack.image_url.clone()]));
1993    }
1994    if !pack.description.is_empty() {
1995        builder = builder
1996            .tag(Tag::custom("description", [pack.description.clone()]))
1997            .tag(Tag::custom("about", [pack.description.clone()]));
1998    }
1999
2000    for e in &pack.emojis {
2001        if e.shortcode.is_empty() || e.url.is_empty() { continue; }
2002        builder = builder.tag(Tag::custom(
2003            "emoji",
2004            [e.shortcode.clone(), e.url.clone()],
2005        ));
2006    }
2007    Ok(builder)
2008}
2009
2010/// Publish (or replace) one of the user's own packs as a kind 30030
2011/// event, persist it locally, and add it to the subscription list so
2012/// the picker surfaces it immediately. std::sync::Arc<crate::db::Session>-gated so a mid-
2013/// network account swap can't push account A's pack signed by B's key.
2014pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
2015    crate::db::scoped(async move {
2016        let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2017        let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2018
2019        // Per-pack emoji cap. Applies to own packs only — shared packs the
2020        // user receives can exceed this, the display layer truncates.
2021        let emoji_cap = effective_max_emojis_per_pack();
2022        if pack.emojis.len() > emoji_cap {
2023            return Err(format!(
2024                "A pack can hold at most {} emojis.",
2025                emoji_cap,
2026            ));
2027        }
2028
2029        // Force `pubkey` + `is_own` regardless of caller — protects against
2030        // a malformed payload claiming ownership of someone else's pack.
2031        let mut to_save = pack.clone();
2032        to_save.pubkey = my_pk.to_hex();
2033        to_save.is_own = true;
2034        let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
2035        to_save.id = naddr_from_addr(&raw_addr)?;
2036        to_save.updated_at = std::time::SystemTime::now()
2037            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
2038
2039        // Equipped-pack cap. Replacing an existing own pack is fine — only
2040        // a *new* identifier would push us over the limit.
2041        {
2042            let existing_subs = load_subscriptions()?;
2043            let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
2044            let cap = effective_max_equipped_packs();
2045            if is_new && existing_subs.len() >= cap {
2046                return Err(format!(
2047                    "You can equip at most {} packs. Remove one to add another.",
2048                    cap,
2049                ));
2050            }
2051        }
2052
2053        let builder = build_pack_event(&to_save)?;
2054        crate::sign_and_send(&client, builder).await
2055            .map_err(|e| format!("publish kind 30030: {}", e))?;
2056
2057
2058        save_pack(&to_save)?;
2059
2060        // Add to local subscriptions so the picker shows it without waiting
2061        // for the next 10030 republish to land.
2062        let mut subs = load_subscriptions()?;
2063        if !subs.iter().any(|a| a == &raw_addr) {
2064            subs.push(raw_addr.clone());
2065            save_subscriptions(&subs)?;
2066            republish_emoji_list_debounced();
2067        }
2068
2069        crate::traits::emit_event("emoji_packs_updated", &());
2070        crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
2071            to_save.identifier, to_save.emojis.len());
2072
2073        Ok(to_save)
2074    })
2075    .await
2076}
2077
2078/// Tombstone one of the user's own packs by publishing an empty kind
2079/// 30030 with just the `d` tag (relays replace the prior payload), drop
2080/// the local subscription, and republish kind 10030.
2081pub async fn delete_own_pack(id: &str) -> Result<(), String> {
2082    crate::db::scoped(async move {
2083        let parsed = parse_naddr(id)?;
2084        let raw_addr = parsed.to_addr_string();
2085        let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2086        if parsed.pubkey != my_pk {
2087            return Err("Cannot delete a pack you don't own".to_string());
2088        }
2089        let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2090
2091        let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2092            .tag(Tag::custom("d", [parsed.identifier.clone()]));
2093        crate::sign_and_send(&client, builder).await
2094            .map_err(|e| format!("publish empty kind 30030: {}", e))?;
2095
2096
2097        // Drop subscription + pack rows (CASCADE wipes pack items).
2098        // Wrapped in a transaction so a crash between the two deletes can't
2099        // leave an orphan subscription pointing at a pack row that's already gone.
2100        {
2101            let mut conn = crate::db::get_write_connection_guard_static()?;
2102            let tx = conn.transaction()
2103                .map_err(|e| format!("begin delete tx: {}", e))?;
2104            tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
2105                rusqlite::params![raw_addr])
2106                .map_err(|e| format!("drop subscription: {}", e))?;
2107            tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
2108                rusqlite::params![raw_addr])
2109                .map_err(|e| format!("drop pack row: {}", e))?;
2110            tx.commit()
2111                .map_err(|e| format!("commit delete tx: {}", e))?;
2112        }
2113
2114        republish_emoji_list_debounced();
2115        crate::traits::emit_event("emoji_packs_updated", &());
2116        crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
2117        Ok(())
2118    })
2119    .await
2120}
2121
2122/// Unsubscribe locally and republish kind 10030 without the pack.
2123/// The pack row itself stays in `emoji_packs` (caller may still want
2124/// to render old reactions); only the subscription link is dropped.
2125pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
2126    crate::db::scoped(async move {
2127        let raw_addr = parse_naddr(id)?.to_addr_string();
2128        let mut subs = load_subscriptions()?;
2129        let before = subs.len();
2130        subs.retain(|a| a != &raw_addr);
2131        if subs.len() == before {
2132            return Ok(()); // not subscribed, noop
2133        }
2134        save_subscriptions(&subs)?;
2135        republish_emoji_list_debounced();
2136        crate::traits::emit_event("emoji_packs_updated", &());
2137        Ok(())
2138    })
2139    .await
2140}
2141
2142/// Persist a user-defined display order for the equipped packs (including the
2143/// theme slot) and republish kind 10030 so it syncs across devices.
2144///
2145/// `ordered_ids` is the FULL display order from the frontend: each element is a
2146/// pack `id` (naddr) for a real subscribed pack, or the literal
2147/// [`THEME_SLOT_TOKEN`] for the theme marker. Real packs are persisted in the
2148/// given order; the anchor is set to the raw addr of the pack immediately
2149/// before the marker (`""` = top / marker absent).
2150pub fn reorder_emoji_packs(ordered_ids: Vec<String>) -> Result<(), String> {
2151    let session = crate::db::current_session();
2152
2153    let mut real_addrs: Vec<String> = Vec::with_capacity(ordered_ids.len());
2154    let mut anchor = String::new();
2155    let mut marker_present = false;
2156    for id in &ordered_ids {
2157        if id == THEME_SLOT_TOKEN {
2158            marker_present = true;
2159            anchor = real_addrs.last().cloned().unwrap_or_default();
2160        } else {
2161            real_addrs.push(parse_naddr(id)?.to_addr_string());
2162        }
2163    }
2164
2165    if !session.is_live() {
2166        return Err("Account swapped during reorder — aborted".to_string());
2167    }
2168    save_subscriptions(&real_addrs)?;
2169    // Only the theme-slot's own drag moves the marker. A reorder that omits it
2170    // (theme pack inactive, so its tab isn't shown) leaves the anchor put, so
2171    // the user's theme-slot placement survives reordering the real packs.
2172    if marker_present {
2173        save_theme_slot_anchor(&anchor)?;
2174    }
2175    republish_emoji_list_debounced();
2176    crate::traits::emit_event("emoji_packs_updated", &());
2177    Ok(())
2178}
2179
2180/// Return the theme-slot anchor as a NADDR (the pack the theme slot renders
2181/// immediately after), or `""` when the slot is at the top. Degrades to top on
2182/// a malformed stored anchor rather than breaking the picker.
2183pub fn get_theme_slot_anchor() -> Result<String, String> {
2184    let raw = load_theme_slot_anchor();
2185    if raw.is_empty() {
2186        return Ok(String::new());
2187    }
2188    Ok(naddr_from_addr(&raw).unwrap_or_default())
2189}
2190
2191// ============================================================================
2192// Tests
2193// ============================================================================
2194
2195#[cfg(test)]
2196mod tests {
2197    use super::*;
2198
2199    fn keys() -> Keys {
2200        Keys::generate()
2201    }
2202
2203    fn build_pack_event(
2204        k: &Keys,
2205        d: &str,
2206        title_tag: Option<(&str, &str)>,
2207        image_tag: Option<(&str, &str)>,
2208        desc_tag: Option<(&str, &str)>,
2209        emojis: &[(&str, &str)],
2210    ) -> Event {
2211        let mut tags: Vec<Tag> = Vec::new();
2212        tags.push(Tag::custom("d", [d]));
2213        if let Some((key, val)) = title_tag {
2214            tags.push(Tag::custom(key, [val]));
2215        }
2216        if let Some((key, val)) = image_tag {
2217            tags.push(Tag::custom(key, [val]));
2218        }
2219        if let Some((key, val)) = desc_tag {
2220            tags.push(Tag::custom(key, [val]));
2221        }
2222        for (code, url) in emojis {
2223            tags.push(Tag::custom("emoji", [*code, *url]));
2224        }
2225        EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2226            .tags(tags)
2227            .finalize(k)
2228            .unwrap()
2229    }
2230
2231    fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2232        let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2233        crate::db::close_database();
2234        // Per-account row-id caches survive close_database; stale entries would
2235        // point into a prior test's DB.
2236        crate::db::clear_id_caches();
2237        let tmp = tempfile::tempdir().unwrap();
2238        let account = keys().public_key().to_bech32().unwrap();
2239        std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2240        crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
2241        crate::db::set_current_account(account.clone()).unwrap();
2242        crate::db::init_database(&account).unwrap();
2243        (tmp, guard)
2244    }
2245
2246    /// Save a parsed pack + return its raw addr.
2247    fn seed_pack(k: &Keys, d: &str, emojis: &[(&str, &str)]) -> String {
2248        let ev = build_pack_event(k, d, None, None, None, emojis);
2249        let pack = parse_pack_from_event(&ev, None).unwrap();
2250        save_pack(&pack).unwrap();
2251        pack.addr()
2252    }
2253
2254    fn seed_usage(url: &str) {
2255        let conn = crate::db::get_write_connection_guard_static().unwrap();
2256        conn.execute(
2257            "INSERT INTO emoji_usage (kind, id, url, score, last_used) VALUES (1, ?1, ?1, 1.0, 0)",
2258            rusqlite::params![url],
2259        ).unwrap();
2260    }
2261
2262    fn usage_count() -> i64 {
2263        let conn = crate::db::get_db_connection_guard_static().unwrap();
2264        conn.query_row("SELECT COUNT(*) FROM emoji_usage", [], |r| r.get(0)).unwrap()
2265    }
2266
2267    fn pack_status(addr: &str) -> u8 {
2268        load_cached_pack(addr).unwrap().unwrap().status
2269    }
2270
2271    fn build_deletion_event(k: &Keys, addr: &str) -> Event {
2272        EventBuilder::new(Kind::EventDeletion, "")
2273            .tag(Tag::custom("a", [addr]))
2274            .finalize(k)
2275            .unwrap()
2276    }
2277
2278    #[test]
2279    fn classify_verdict_table() {
2280        let k = keys();
2281        let live = build_pack_event(&k, "p", None, None, None, &[("a", "https://e.x/a.png")]);
2282        let empty = build_pack_event(&k, "p", None, None, None, &[]);
2283        let addr = format!("30030:{}:p", k.public_key().to_hex());
2284        let del = build_deletion_event(&k, &addr);
2285
2286        // Live event, no deletion → Found (regardless of the absence gate).
2287        let (o, p) = classify_pack(Some(&live), None, None, false);
2288        assert_eq!(o, PackFetchOutcome::Found);
2289        assert!(p.is_some());
2290
2291        // Empty replacement (Vector's own delete shape) → Tombstoned.
2292        let (o, _) = classify_pack(Some(&empty), None, None, true);
2293        assert_eq!(o, PackFetchOutcome::Tombstoned);
2294
2295        // Kind-5 only, no event at all → Tombstoned.
2296        let (o, _) = classify_pack(None, Some(&del), None, true);
2297        assert_eq!(o, PackFetchOutcome::Tombstoned);
2298
2299        // Timestamps pinned: a second boundary between two wall-clock builders
2300        // would otherwise flip the comparison and flake the test.
2301        let ts = Timestamp::from_secs(1_700_000_000);
2302        let live_pinned = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2303            .tags(vec![
2304                Tag::custom("d", ["p"]),
2305                Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2306            ])
2307            .custom_created_at(ts)
2308            .finalize(&k)
2309            .unwrap();
2310        let del_at = |secs: u64| {
2311            EventBuilder::new(Kind::EventDeletion, "")
2312                .tag(Tag::custom("a", [addr.as_str()]))
2313                .custom_created_at(Timestamp::from_secs(secs))
2314                .finalize(&k)
2315                .unwrap()
2316        };
2317
2318        // Kind-5 with an EQUAL timestamp to a live event: the pack wins
2319        // (fail-safe toward alive).
2320        let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_000)), None, false);
2321        assert_eq!(o, PackFetchOutcome::Found);
2322        // A NEWER deletion tombstones the older live event.
2323        let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_001)), None, false);
2324        assert_eq!(o, PackFetchOutcome::Tombstoned);
2325        // An OLDER deletion loses to a republish.
2326        let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_699_999_999)), None, false);
2327        assert_eq!(o, PackFetchOutcome::Found);
2328
2329        // Nothing found: gate decides.
2330        let (o, _) = classify_pack(None, None, None, true);
2331        assert_eq!(o, PackFetchOutcome::CleanMiss);
2332        let (o, _) = classify_pack(None, None, None, false);
2333        assert_eq!(o, PackFetchOutcome::Unreachable);
2334    }
2335
2336    #[test]
2337    fn classify_invalid_emojis_is_not_a_tombstone() {
2338        // An event that HAS emoji tags Vector merely can't validate (bad
2339        // shortcode charset) must judge nothing, not brand the author.
2340        let k = keys();
2341        let ev = build_pack_event(&k, "weird", None, None, None, &[("bad!code", "https://e.x/a.png")]);
2342        assert!(parse_pack_from_event(&ev, None).is_none(), "precondition: unparseable");
2343        let (o, _) = classify_pack(Some(&ev), None, None, true);
2344        assert_eq!(o, PackFetchOutcome::Unreachable);
2345    }
2346
2347    #[test]
2348    fn absence_gate_table() {
2349        let some_writes = vec![RelayUrl::parse("wss://relay.author.example").unwrap()];
2350        let no_writes: Vec<RelayUrl> = Vec::new();
2351
2352        // Home failed or too few live relays: never clean.
2353        assert!(!absence_is_clean(false, 5, Some(&no_writes), true, 1));
2354        assert!(!absence_is_clean(true, 1, Some(&no_writes), true, 1));
2355        // Author outbox unknown (NIP-65 cold / fetch failed): not clean —
2356        // the pack's canonical home was never really checked.
2357        assert!(!absence_is_clean(true, 3, None, true, 1));
2358        // Author verifiably lists no write relays: home evidence suffices.
2359        assert!(absence_is_clean(true, 2, Some(&no_writes), false, 0));
2360        // Author outbox known: it must have been swept live.
2361        assert!(!absence_is_clean(true, 3, Some(&some_writes), false, 0));
2362        assert!(!absence_is_clean(true, 3, Some(&some_writes), true, 0));
2363        assert!(absence_is_clean(true, 3, Some(&some_writes), true, 1));
2364    }
2365
2366    #[test]
2367    fn deletion_matching_is_author_bound() {
2368        let author = keys();
2369        let stranger = keys();
2370        let addr = format!("30030:{}:p", author.public_key().to_hex());
2371        // A third party citing the coordinate must never count as a revocation.
2372        let forged = build_deletion_event(&stranger, &addr);
2373        assert!(!deletion_matches(&forged, &author.public_key(), &addr));
2374        let real = build_deletion_event(&author, &addr);
2375        assert!(deletion_matches(&real, &author.public_key(), &addr));
2376    }
2377
2378    #[test]
2379    fn tombstone_revokes_immediately_and_purges_frecency() {
2380        let (_tmp, _guard) = init_test_db();
2381        let k = keys();
2382        let addr = seed_pack(&k, "deadpack", &[("boom", "https://e.x/boom.png")]);
2383        seed_usage("https://e.x/boom.png");
2384        seed_usage("https://e.x/unrelated.png");
2385
2386        let changed = apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2387        assert!(changed, "tombstone must flip status in one sweep");
2388        assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2389        assert_eq!(usage_count(), 1, "only the dead pack's usage rows purge");
2390    }
2391
2392    #[test]
2393    fn found_self_heals_from_revoked() {
2394        let (_tmp, _guard) = init_test_db();
2395        let k = keys();
2396        let addr = seed_pack(&k, "phoenix", &[("rise", "https://e.x/r.png")]);
2397        apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2398        assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2399
2400        let changed = apply_pack_health(&addr, &PackFetchOutcome::Found, 2_000).unwrap();
2401        assert!(changed);
2402        assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "a republished pack revives");
2403    }
2404
2405    #[test]
2406    fn clean_miss_gauntlet_rate_limit_and_promotion() {
2407        let (_tmp, _guard) = init_test_db();
2408        let k = keys();
2409        let addr = seed_pack(&k, "fading", &[("bye", "https://e.x/bye.png")]);
2410        let t0: i64 = 1_000_000;
2411        let h = 3_600;
2412
2413        // First miss counts; a second inside the rate-limit window doesn't.
2414        assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0).unwrap());
2415        assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + h).unwrap());
2416        // Two more spaced misses reach the count, but 48h hasn't elapsed yet.
2417        assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 13 * h).unwrap());
2418        assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 26 * h).unwrap());
2419        assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "count alone must not promote");
2420        // Past both thresholds: promoted.
2421        let changed = apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 49 * h).unwrap();
2422        assert!(changed);
2423        assert_eq!(pack_status(&addr), PACK_STATUS_MISSING);
2424    }
2425
2426    #[test]
2427    fn unreachable_never_moves_counters() {
2428        let (_tmp, _guard) = init_test_db();
2429        let k = keys();
2430        let addr = seed_pack(&k, "offline", &[("zz", "https://e.x/z.png")]);
2431        for t in [1_000i64, 200_000, 400_000, 800_000] {
2432            assert!(!apply_pack_health(&addr, &PackFetchOutcome::Unreachable, t).unwrap());
2433        }
2434        assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE);
2435        let conn = crate::db::get_db_connection_guard_static().unwrap();
2436        let miss: i64 = conn.query_row(
2437            "SELECT miss_count FROM emoji_packs WHERE addr = ?1",
2438            rusqlite::params![addr], |r| r.get(0),
2439        ).unwrap();
2440        assert_eq!(miss, 0, "offline sweeps must not accrue misses");
2441    }
2442
2443    #[test]
2444    fn revoked_ignores_clean_misses() {
2445        let (_tmp, _guard) = init_test_db();
2446        let k = keys();
2447        let addr = seed_pack(&k, "sealed", &[("x", "https://e.x/x.png")]);
2448        apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2449        for t in [100_000i64, 300_000, 600_000] {
2450            assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t).unwrap());
2451        }
2452        assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED, "misses can't downgrade a tombstone verdict");
2453    }
2454
2455    #[test]
2456    fn empty_pack_event_is_the_tombstone_shape() {
2457        // Vector's own delete flow publishes an empty replacement; the parser
2458        // must keep rejecting it so the sweep can read that rejection as a
2459        // deterministic tombstone.
2460        let k = keys();
2461        let ev = build_pack_event(&k, "gone", None, None, None, &[]);
2462        assert!(parse_pack_from_event(&ev, None).is_none());
2463    }
2464
2465    #[test]
2466    fn parse_pack_reads_nip51_spec_tags() {
2467        let k = keys();
2468        let ev = build_pack_event(
2469            &k, "myPack",
2470            Some(("title", "Spec Pack")),
2471            Some(("image", "https://example.com/p.png")),
2472            Some(("description", "specd")),
2473            &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
2474        );
2475        let pack = parse_pack_from_event(&ev, None).unwrap();
2476        assert_eq!(pack.identifier, "myPack");
2477        assert_eq!(pack.title, "Spec Pack");
2478        assert_eq!(pack.image_url, "https://example.com/p.png");
2479        assert_eq!(pack.description, "specd");
2480        assert_eq!(pack.emojis.len(), 2);
2481        assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
2482    }
2483
2484    #[test]
2485    fn own_pack_event_carries_the_vector_client_tag() {
2486        // Packs we publish/edit must be attributable to Vector, matching the
2487        // ["client","vector"] tag our kind-0 profile publishes carry.
2488        let k = keys();
2489        let ev = build_pack_event(&k, "myPack", Some(("title", "Mine")), None, None, &[("smile", "https://e.x/s.png")]);
2490        let pack = parse_pack_from_event(&ev, None).unwrap();
2491        let built = super::build_pack_event(&pack).unwrap().finalize(&k).unwrap();
2492        let has_client = built.tags.iter().any(|t| {
2493            let s = t.as_slice();
2494            s.first().map(String::as_str) == Some("client") && s.get(1).map(String::as_str) == Some("vector")
2495        });
2496        assert!(has_client, "own pack event must carry the [\"client\",\"vector\"] tag");
2497    }
2498
2499    #[test]
2500    fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
2501        let k = keys();
2502        let ev = build_pack_event(
2503            &k, "ditto",
2504            Some(("name", "Ditto Pack")),
2505            Some(("picture", "https://example.com/d.png")),
2506            Some(("about", "ditto-style")),
2507            &[("yes", "https://e.x/y.png")],
2508        );
2509        let pack = parse_pack_from_event(&ev, None).unwrap();
2510        assert_eq!(pack.title, "Ditto Pack");
2511        assert_eq!(pack.image_url, "https://example.com/d.png");
2512        assert_eq!(pack.description, "ditto-style");
2513    }
2514
2515    #[test]
2516    fn parse_pack_prefers_spec_tags_over_ditto() {
2517        let k = keys();
2518        let mut tags: Vec<Tag> = vec![
2519            Tag::custom("d", ["both"]),
2520            Tag::custom("title", ["SpecTitle"]),
2521            Tag::custom("name", ["DittoName"]),
2522            Tag::custom("image", ["spec.png"]),
2523            Tag::custom("picture", ["ditto.png"]),
2524            Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2525        ];
2526        tags.extend(std::iter::empty());
2527        let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2528            .tags(tags).finalize(&k).unwrap();
2529        let pack = parse_pack_from_event(&ev, None).unwrap();
2530        assert_eq!(pack.title, "SpecTitle");
2531        assert_eq!(pack.image_url, "spec.png");
2532    }
2533
2534    #[test]
2535    fn parse_pack_returns_none_without_d_tag() {
2536        let k = keys();
2537        let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2538            .tags(vec![
2539                Tag::custom("title", ["No D"]),
2540                Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2541            ])
2542            .finalize(&k).unwrap();
2543        assert!(parse_pack_from_event(&ev, None).is_none());
2544    }
2545
2546    #[test]
2547    fn parse_pack_returns_none_when_no_valid_emojis() {
2548        let k = keys();
2549        let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
2550        assert!(parse_pack_from_event(&ev, None).is_none());
2551    }
2552
2553    #[test]
2554    fn parse_pack_rejects_invalid_shortcodes() {
2555        let k = keys();
2556        let ev = build_pack_event(
2557            &k, "mix", Some(("title", "Mix")), None, None,
2558            &[("ok_name", "https://e.x/a.png"),
2559              ("bad name", "https://e.x/b.png"),
2560              ("colons:no", "https://e.x/c.png"),
2561              ("", "https://e.x/d.png"),
2562              ("dash-ok", "https://e.x/e.png")],
2563        );
2564        let pack = parse_pack_from_event(&ev, None).unwrap();
2565        let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
2566        assert_eq!(codes, vec!["ok_name", "dash-ok"]);
2567    }
2568
2569    #[test]
2570    fn parse_pack_dedupes_shortcodes_first_wins() {
2571        let k = keys();
2572        let ev = build_pack_event(
2573            &k, "dup", Some(("title", "Dup")), None, None,
2574            &[("smile", "https://e.x/first.png"),
2575              ("smile", "https://e.x/second.png")],
2576        );
2577        let pack = parse_pack_from_event(&ev, None).unwrap();
2578        assert_eq!(pack.emojis.len(), 1);
2579        assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
2580    }
2581
2582    #[test]
2583    fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
2584        let k = keys();
2585        let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
2586            &[("a", "https://e.x/a.png")]);
2587        let my_hex = k.public_key().to_hex();
2588        let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
2589        assert!(pack_mine.is_own);
2590
2591        let stranger = keys().public_key().to_hex();
2592        let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
2593        assert!(!pack_other.is_own);
2594    }
2595
2596    #[test]
2597    fn pack_address_to_string_round_trips() {
2598        let k = keys();
2599        let hex = k.public_key().to_hex();
2600        let addr = PackAddress {
2601            kind: 30030,
2602            pubkey: k.public_key(),
2603            identifier: "myPack".to_string(),
2604        };
2605        assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
2606        let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
2607        assert_eq!(parsed, addr);
2608    }
2609
2610    #[test]
2611    fn parse_naddr_round_trips_kind_30030_coordinate() {
2612        // Construct a synthetic naddr via nostr-sdk and verify our decoder
2613        // round-trips kind / pubkey / identifier.
2614        let k = keys();
2615        let coord = nostr_sdk::prelude::nip01::Coordinate {
2616            kind: Kind::Custom(30030),
2617            public_key: k.public_key(),
2618            identifier: "trip".to_string(),
2619        };
2620        let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2621            coordinate: coord,
2622            relays: Vec::new(),
2623        };
2624        let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2625        let parsed = parse_naddr(&naddr).unwrap();
2626        assert_eq!(parsed.kind, 30030);
2627        assert_eq!(parsed.pubkey, k.public_key());
2628        assert_eq!(parsed.identifier, "trip");
2629    }
2630
2631    #[test]
2632    fn parse_naddr_rejects_non_30030_kinds() {
2633        let k = keys();
2634        let coord = nostr_sdk::prelude::nip01::Coordinate {
2635            kind: Kind::Custom(30023), // long-form article
2636            public_key: k.public_key(),
2637            identifier: "essay".to_string(),
2638        };
2639        let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2640            coordinate: coord,
2641            relays: Vec::new(),
2642        };
2643        let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2644        let err = parse_naddr(&naddr).unwrap_err();
2645        assert!(err.contains("expected kind 30030"),
2646            "expected kind-rejection error, got: {}", err);
2647    }
2648
2649    #[test]
2650    fn parse_naddr_strips_nostr_uri_prefix() {
2651        let k = keys();
2652        let coord = nostr_sdk::prelude::nip01::Coordinate {
2653            kind: Kind::Custom(30030),
2654            public_key: k.public_key(),
2655            identifier: "prefixed".to_string(),
2656        };
2657        let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2658            coordinate: coord,
2659            relays: Vec::new(),
2660        };
2661        let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2662        let with_prefix = format!("nostr:{}", naddr);
2663        let parsed = parse_naddr(&with_prefix).unwrap();
2664        assert_eq!(parsed.identifier, "prefixed");
2665    }
2666
2667    #[test]
2668    fn parse_naddr_rejects_garbage_input() {
2669        assert!(parse_naddr("not an naddr").is_err());
2670        assert!(parse_naddr("naddr1invalid").is_err());
2671        assert!(parse_naddr("").is_err());
2672    }
2673
2674    #[test]
2675    fn parse_pack_address_round_trips_valid_input() {
2676        let k = keys();
2677        let hex = k.public_key().to_hex();
2678        let addr = format!("30030:{}:myId", hex);
2679        let parsed = parse_pack_address(&addr).unwrap();
2680        assert_eq!(parsed.kind, 30030);
2681        assert_eq!(parsed.pubkey, k.public_key());
2682        assert_eq!(parsed.identifier, "myId");
2683    }
2684
2685    #[test]
2686    fn parse_pack_address_rejects_wrong_kind() {
2687        let hex = keys().public_key().to_hex();
2688        let addr = format!("10030:{}:x", hex);
2689        assert!(parse_pack_address(&addr).is_err());
2690    }
2691
2692    #[test]
2693    fn parse_pack_address_rejects_malformed_pubkey() {
2694        let addr = "30030:not-hex:x".to_string();
2695        assert!(parse_pack_address(&addr).is_err());
2696    }
2697
2698    #[test]
2699    fn parse_pack_address_preserves_colons_in_identifier() {
2700        // d-tag values can be arbitrary strings, including colons.
2701        let hex = keys().public_key().to_hex();
2702        let addr = format!("30030:{}:id:with:colons", hex);
2703        let parsed = parse_pack_address(&addr).unwrap();
2704        assert_eq!(parsed.identifier, "id:with:colons");
2705    }
2706
2707    #[test]
2708    fn parse_inner_tag_list_extracts_valid_a_tags() {
2709        // The inner tag list lives JSON-encoded inside the NIP-44-encrypted
2710        // event content; exercise the parser directly so we don't pull a
2711        // signer + network into a unit test.
2712        let hex_a = keys().public_key().to_hex();
2713        let hex_b = keys().public_key().to_hex();
2714        let plaintext = format!(
2715            r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
2716            a = hex_a,
2717            b = hex_b,
2718        );
2719        let addrs = parse_inner_tag_list(&plaintext);
2720        assert_eq!(addrs.len(), 2);
2721        assert_eq!(addrs[0].identifier, "packA");
2722        assert_eq!(addrs[1].identifier, "packB");
2723    }
2724
2725    #[test]
2726    fn parse_inner_tag_list_returns_empty_on_malformed_json() {
2727        assert!(parse_inner_tag_list("not json").is_empty());
2728        assert!(parse_inner_tag_list("").is_empty());
2729    }
2730
2731    #[test]
2732    fn theme_slot_marker_round_trips_through_publish_and_parse() {
2733        // Three packs, theme slot anchored after the 2nd. Build the inner
2734        // tuples exactly as publish does, serialise, then parse back.
2735        let a = keys().public_key().to_hex();
2736        let b = keys().public_key().to_hex();
2737        let c = keys().public_key().to_hex();
2738        let addr_a = format!("30030:{}:packA", a);
2739        let addr_b = format!("30030:{}:packB", b);
2740        let addr_c = format!("30030:{}:packC", c);
2741        let addrs = vec![addr_a.clone(), addr_b.clone(), addr_c.clone()];
2742
2743        let inner = build_inner_tags_with_marker(&addrs, &addr_b);
2744        let marker_count = inner.iter()
2745            .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2746            .count();
2747        assert_eq!(marker_count, 1, "exactly one marker is emitted");
2748
2749        let plaintext = serde_json::to_string(&inner).unwrap();
2750        let (parsed, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
2751        assert_eq!(parsed, addrs, "addr order survives the round trip");
2752        assert_eq!(anchor.as_deref(), Some(addr_b.as_str()), "anchor is the 2nd pack");
2753
2754        // Old-format list (no marker tuple) → None anchor.
2755        let old = format!(r#"[["a","{}"],["a","{}"]]"#, addr_a, addr_b);
2756        let (old_addrs, old_anchor) = parse_inner_tag_list_with_anchor(&old);
2757        assert_eq!(old_addrs, vec![addr_a.clone(), addr_b.clone()]);
2758        assert_eq!(old_anchor, None, "a list without the marker has no anchor");
2759
2760        // Marker at the top → empty-string anchor.
2761        let top = build_inner_tags_with_marker(&addrs, "");
2762        assert_eq!(top[0], vec![THEME_SLOT_TOKEN.to_string()]);
2763        let (_, top_anchor) =
2764            parse_inner_tag_list_with_anchor(&serde_json::to_string(&top).unwrap());
2765        assert_eq!(top_anchor.as_deref(), Some(""), "top slot = empty anchor");
2766
2767        // Anchor naming a pack no longer subscribed falls back to the top.
2768        let gone = format!("30030:{}:ghost", keys().public_key().to_hex());
2769        let fallback = build_inner_tags_with_marker(&addrs, &gone);
2770        assert_eq!(fallback[0], vec![THEME_SLOT_TOKEN.to_string()]);
2771        let fb_markers = fallback.iter()
2772            .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2773            .count();
2774        assert_eq!(fb_markers, 1, "still exactly one marker on the fallback path");
2775    }
2776
2777    #[test]
2778    fn shortcode_validator_accepts_alphanum_dash_underscore() {
2779        assert!(is_valid_shortcode("smile"));
2780        assert!(is_valid_shortcode("smile_face"));
2781        assert!(is_valid_shortcode("smile-face"));
2782        assert!(is_valid_shortcode("Smile2"));
2783        assert!(!is_valid_shortcode(""));
2784        assert!(!is_valid_shortcode("smile face"));
2785        assert!(!is_valid_shortcode("smile:face"));
2786        assert!(!is_valid_shortcode("😀"));
2787        // `~` is reserved for message-tag disambiguation, NOT valid in
2788        // pack-authored shortcodes.
2789        assert!(!is_valid_shortcode("love~2"));
2790    }
2791
2792    #[test]
2793    fn resolve_token_disambiguates_duplicate_shortcodes() {
2794        // Two distinct images share `:love:`, sorted lexicographically by URL.
2795        let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
2796        by_code.insert(
2797            "love".to_string(),
2798            vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
2799        );
2800        by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
2801
2802        // Plain code → first candidate (matches a bare `:love:`).
2803        assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
2804        // `~1` / `~2` select by 1-based index.
2805        assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
2806        assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
2807        // Out-of-range / zero / unknown base → nothing (renders literal).
2808        assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
2809        assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
2810        assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
2811        // Non-colliding code resolves bare; a stray `~N` on it is out of range.
2812        assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
2813        assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
2814    }
2815
2816    #[test]
2817    fn message_emoji_tags_accept_disambiguation_separator() {
2818        // Inbound `love~2` tags must survive parsing so the recipient renders.
2819        let tags = vec![
2820            vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
2821            vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
2822        ];
2823        let out = crate::types::EmojiTag::extract_from_stored(&tags);
2824        assert_eq!(out.len(), 1);
2825        assert_eq!(out[0].shortcode, "love~2");
2826    }
2827}