Skip to main content

vector_core/
emoji_packs.rs

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