Skip to main content

vector_core/
emoji_packs.rs

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