Skip to main content

vector_core/
emoji_packs.rs

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