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