1use std::collections::HashMap;
16
17use nostr_sdk::prelude::*;
18use serde::{Deserialize, Serialize};
19
20use crate::state::nostr_client;
21
22const KIND_EMOJI_LIST: u16 = 10030;
24
25pub const KIND_EMOJI_SET: u16 = 30030;
27
28const KIND_EMOJI_SET_ADDR_PREFIX: &str = "30030:";
34const _: () = assert!(
35 KIND_EMOJI_SET == 30030,
36 "KIND_EMOJI_SET_ADDR_PREFIX literal must match KIND_EMOJI_SET"
37);
38
39fn build_pack_addr(pubkey: &str, identifier: &str) -> String {
44 let mut s = String::with_capacity(
45 KIND_EMOJI_SET_ADDR_PREFIX.len() + pubkey.len() + 1 + identifier.len(),
46 );
47 s.push_str(KIND_EMOJI_SET_ADDR_PREFIX);
48 s.push_str(pubkey);
49 s.push(':');
50 s.push_str(identifier);
51 s
52}
53
54const FETCH_TIMEOUT_SECS: u64 = 20;
60
61const EQUIPPED_PACKS_BY_TIER: [usize; 4] = [3, 6, 9, usize::MAX];
66const EMOJIS_PER_PACK_BY_TIER: [usize; 4] = [30, 30, 60, 90];
68
69pub const MAX_EQUIPPED_PACKS: usize = EQUIPPED_PACKS_BY_TIER[0];
71
72pub const MAX_EMOJIS_PER_PACK: usize = EMOJIS_PER_PACK_BY_TIER[0];
76
77pub fn effective_max_equipped_packs() -> usize {
80 EQUIPPED_PACKS_BY_TIER[crate::badges::effective_tier() as usize]
81}
82
83pub fn effective_max_emojis_per_pack() -> usize {
85 EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
86}
87
88#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
93pub struct PackEmoji {
94 pub shortcode: String,
95 pub url: String,
96 pub sha256: Option<String>,
97}
98
99#[derive(Serialize, Deserialize, Clone, Debug)]
100pub struct EmojiPack {
101 pub id: String,
108 pub pubkey: String,
110 pub identifier: String,
112 pub title: String,
114 pub image_url: String,
116 pub description: String,
118 pub emojis: Vec<PackEmoji>,
119 pub is_own: bool,
121 pub updated_at: u64,
124 pub status: u8,
129}
130
131pub const PACK_STATUS_ACTIVE: u8 = 0;
133pub const PACK_STATUS_REVOKED: u8 = 1;
137pub const PACK_STATUS_MISSING: u8 = 2;
141
142#[derive(Debug, Clone, PartialEq)]
144pub enum PackFetchOutcome {
145 Found,
147 Tombstoned,
149 CleanMiss,
151 Unreachable,
153}
154
155const PACK_MISS_PROMOTE_COUNT: i64 = 3;
159const PACK_MISS_PROMOTE_SECS: i64 = 48 * 3600;
160const PACK_MISS_RATELIMIT_SECS: i64 = 12 * 3600;
163
164impl EmojiPack {
165 pub fn addr(&self) -> String {
169 build_pack_addr(&self.pubkey, &self.identifier)
170 }
171}
172
173fn is_valid_shortcode(s: &str) -> bool {
181 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
182}
183
184static THEME_EMOJI_TAGS: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
189 std::sync::OnceLock::new();
190
191fn theme_emoji_tags() -> &'static std::sync::Mutex<Vec<(String, String)>> {
192 THEME_EMOJI_TAGS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
193}
194
195pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
198 if let Ok(mut g) = theme_emoji_tags().lock() {
199 *g = tags;
200 }
201}
202
203pub fn effective_display_cap() -> usize {
209 EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
210}
211
212fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
217 if let Some((base, suffix)) = token.rsplit_once('~') {
218 let n: usize = suffix.parse().ok()?;
219 if n == 0 { return None; }
220 return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
221 }
222 by_code.get(token).and_then(|urls| urls.first().cloned())
223}
224
225pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
234 if content.is_empty() || !content.contains(':') {
235 return Vec::new();
236 }
237
238 let cap = effective_display_cap();
240 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
241
242 if let Ok(conn) = crate::db::get_db_connection_guard_static() {
248 if let Ok(mut stmt) = conn.prepare(
249 "SELECT p.addr, i.shortcode, i.url
250 FROM emoji_pack_items i
251 INNER JOIN emoji_packs p ON p.addr = i.pack_addr
252 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
253 WHERE p.status = 0
254 ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
255 ) {
256 if let Ok(rows) = stmt.query_map([], |row| {
257 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
258 }) {
259 let mut per_pack: HashMap<String, usize> = HashMap::new();
262 for (addr, code, url) in rows.flatten() {
263 let n = per_pack.entry(addr).or_insert(0);
264 if *n >= cap { continue; }
265 *n += 1;
266 by_code.entry(code).or_default().push(url);
267 }
268 }
269 }
270 }
271
272 if let Ok(theme) = theme_emoji_tags().lock() {
276 for (code, url) in theme.iter() {
277 by_code.entry(code.clone()).or_default().push(url.clone());
278 }
279 }
280
281 if by_code.is_empty() {
282 return Vec::new();
283 }
284
285 for urls in by_code.values_mut() {
288 urls.sort();
289 urls.dedup();
290 }
291
292 let mut out: Vec<crate::types::EmojiTag> = Vec::new();
293 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
294 let bytes = content.as_bytes();
295 let mut i = 0;
296 while i < bytes.len() {
297 if bytes[i] == b':' {
298 let start = i + 1;
299 let mut j = start;
300 while j < bytes.len() {
301 let c = bytes[j];
302 let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
304 if !ok { break; }
305 j += 1;
306 }
307 if j > start && j < bytes.len() && bytes[j] == b':' {
308 if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
309 if !seen.contains(token) {
310 if let Some(url) = resolve_emoji_token(&by_code, token) {
311 out.push(crate::types::EmojiTag {
312 shortcode: token.to_string(),
313 url,
314 });
315 seen.insert(token.to_string());
316 }
317 }
318 }
319 i = j + 1;
320 continue;
321 }
322 }
323 i += 1;
324 }
325 out
326}
327
328fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
331 for key in keys {
332 for tag in tags.iter() {
333 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
334 if parts.len() >= 2 && parts[0] == *key {
335 return Some(parts[1].to_string());
336 }
337 }
338 }
339 None
340}
341
342pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
345 if event.kind.as_u16() != KIND_EMOJI_SET {
346 return None;
347 }
348
349 let identifier = first_tag(&event.tags, &["d"])?;
350 let pubkey = event.pubkey.to_hex();
351 let addr = build_pack_addr(&pubkey, &identifier);
352 let id = match naddr_from_addr(&addr) {
353 Ok(s) => s,
354 Err(e) => {
355 crate::log_warn!(
356 "[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
357 addr, e,
358 );
359 return None;
360 }
361 };
362
363 let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
364 let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
365 let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
366
367 let mut emojis = Vec::new();
368 let mut seen = std::collections::HashSet::new();
369 for tag in event.tags.iter() {
370 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
371 if parts.len() >= 3 && parts[0] == "emoji" {
372 let shortcode = parts[1];
373 if !is_valid_shortcode(shortcode) { continue; }
374 let url = parts[2].trim();
378 if url.is_empty() { continue; }
379 if !seen.insert(shortcode.to_string()) { continue; }
380 emojis.push(PackEmoji {
381 shortcode: shortcode.to_string(),
382 url: url.to_string(),
383 sha256: None,
384 });
385 }
386 }
387
388 if emojis.is_empty() {
389 return None;
390 }
391
392 let is_own = my_pubkey_hex.map_or(false, |me| me == pubkey);
393
394 Some(EmojiPack {
395 id,
396 pubkey,
397 identifier,
398 title,
399 image_url,
400 description,
401 emojis,
402 is_own,
403 updated_at: event.created_at.as_secs(),
404 status: PACK_STATUS_ACTIVE,
405 })
406}
407
408#[derive(Debug, Clone, PartialEq)]
410pub struct PackAddress {
411 pub kind: u16,
412 pub pubkey: PublicKey,
413 pub identifier: String,
414}
415
416pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
420 let mut parts = addr.splitn(3, ':');
421 let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
422 let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
423 let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
424
425 let kind: u16 = kind_str.parse()
426 .map_err(|_| format!("invalid kind: {}", kind_str))?;
427 if kind != KIND_EMOJI_SET {
428 return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
429 }
430 let pubkey = PublicKey::from_hex(pubkey_str)
431 .map_err(|e| format!("invalid pubkey: {}", e))?;
432
433 Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
434}
435
436impl PackAddress {
437 pub fn to_addr_string(&self) -> String {
442 debug_assert_eq!(self.kind, KIND_EMOJI_SET,
443 "PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
444 build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
445 }
446}
447
448pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
452 let parsed = parse_pack_address(addr)?;
453 let coord = nostr_sdk::nips::nip01::Coordinate {
454 kind: Kind::Custom(parsed.kind),
455 public_key: parsed.pubkey,
456 identifier: parsed.identifier,
457 };
458 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
459 coordinate: coord,
460 relays: Vec::new(),
461 };
462 nostr_sdk::nips::nip19::Nip19::Coordinate(n19)
463 .to_bech32()
464 .map_err(|e| format!("encode naddr: {}", e))
465}
466
467pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
471 let trimmed = naddr.trim().trim_start_matches("nostr:");
472 let parsed = nostr_sdk::nips::nip19::Nip19::from_bech32(trimmed)
473 .map_err(|e| format!("invalid naddr: {}", e))?;
474 let coord = match parsed {
475 nostr_sdk::nips::nip19::Nip19::Coordinate(c) => c,
476 _ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
477 };
478 let kind = coord.kind.as_u16();
479 if kind != KIND_EMOJI_SET {
480 return Err(format!(
481 "expected kind {} (emoji set), got {}",
482 KIND_EMOJI_SET, kind,
483 ));
484 }
485 Ok(PackAddress {
486 kind,
487 pubkey: coord.public_key,
488 identifier: coord.identifier.clone(),
489 })
490}
491
492const THEME_SLOT_TOKEN: &str = "theme_slot";
498
499fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
504 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
505 Ok(v) => v,
506 Err(e) => {
507 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
508 return Vec::new();
509 }
510 };
511 inner.into_iter()
512 .filter_map(|tup| {
513 if tup.len() >= 2 && tup[0] == "a" {
514 parse_pack_address(&tup[1]).ok()
515 } else {
516 None
517 }
518 })
519 .collect()
520}
521
522fn parse_inner_tag_list_with_anchor(plaintext: &str) -> (Vec<String>, Option<String>) {
529 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
530 Ok(v) => v,
531 Err(e) => {
532 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
533 return (Vec::new(), None);
534 }
535 };
536 let mut addrs: Vec<String> = Vec::new();
537 let mut anchor: Option<String> = None;
538 for tup in inner {
539 if tup.len() >= 2 && tup[0] == "a" {
540 if let Ok(pa) = parse_pack_address(&tup[1]) {
543 addrs.push(pa.to_addr_string());
544 }
545 } else if anchor.is_none() && tup.first().map(String::as_str) == Some(THEME_SLOT_TOKEN) {
546 anchor = Some(addrs.last().cloned().unwrap_or_default());
549 }
550 }
551 (addrs, anchor)
552}
553
554pub async fn decrypt_subscribed_addresses(
562 client: &Client,
563 my_pk: &PublicKey,
564 event: &Event,
565) -> Vec<PackAddress> {
566 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
567 Some(plaintext) => parse_inner_tag_list(&plaintext),
568 None => Vec::new(),
569 }
570}
571
572async fn decrypt_emoji_list_plaintext(
575 client: &Client,
576 my_pk: &PublicKey,
577 event: &Event,
578) -> Option<String> {
579 if event.content.is_empty() {
580 return None;
581 }
582 let signer = match client.signer().await {
583 Ok(s) => s,
584 Err(e) => {
585 crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
586 return None;
587 }
588 };
589 match signer.nip44_decrypt(my_pk, &event.content).await {
590 Ok(p) => Some(p),
591 Err(e) => {
592 crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
593 None
594 }
595 }
596}
597
598async fn decrypt_subscribed_addresses_with_anchor(
601 client: &Client,
602 my_pk: &PublicKey,
603 event: &Event,
604) -> (Vec<PackAddress>, Option<String>) {
605 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
606 Some(plaintext) => {
607 let (raw, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
608 let addrs = raw.iter().filter_map(|s| parse_pack_address(s).ok()).collect();
609 (addrs, anchor)
610 }
611 None => (Vec::new(), None),
612 }
613}
614
615pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
620 let mut conn = crate::db::get_write_connection_guard_static()?;
621 let tx = conn.transaction()
622 .map_err(|e| format!("Failed to start tx: {}", e))?;
623
624 let addr = pack.addr();
625 tx.execute(
626 "INSERT OR REPLACE INTO emoji_packs
627 (addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
628 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
629 rusqlite::params![
630 addr, pack.pubkey, pack.identifier,
631 pack.title, pack.image_url, pack.description,
632 pack.is_own as i32, pack.updated_at as i64,
633 ],
634 ).map_err(|e| format!("Failed to upsert pack: {}", e))?;
635
636 tx.execute(
640 "DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
641 rusqlite::params![addr],
642 ).map_err(|e| format!("Failed to clear pack items: {}", e))?;
643
644 for (pos, emoji) in pack.emojis.iter().enumerate() {
645 tx.execute(
646 "INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
647 VALUES (?1, ?2, ?3, ?4, ?5)",
648 rusqlite::params![
649 addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
650 ],
651 ).map_err(|e| format!("Failed to insert pack item: {}", e))?;
652 }
653
654 tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
655 Ok(())
656}
657
658pub fn apply_pack_health(addr: &str, outcome: &PackFetchOutcome, now: i64) -> Result<bool, String> {
675 use rusqlite::OptionalExtension;
676 if matches!(outcome, PackFetchOutcome::Unreachable) {
677 return Ok(false);
678 }
679 let mut conn = crate::db::get_write_connection_guard_static()?;
683 let tx = conn
684 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
685 .map_err(|e| format!("begin pack health tx: {}", e))?;
686
687 let row = tx
688 .query_row(
689 "SELECT status, miss_count, first_missed_at, last_miss_counted_at
690 FROM emoji_packs WHERE addr = ?1",
691 rusqlite::params![addr],
692 |r| {
693 Ok((
694 r.get::<_, i64>(0)?,
695 r.get::<_, i64>(1)?,
696 r.get::<_, i64>(2)?,
697 r.get::<_, i64>(3)?,
698 ))
699 },
700 )
701 .optional()
702 .map_err(|e| format!("read pack health: {}", e))?;
703 let Some((status, miss_count, first_missed_at, last_miss_counted_at)) = row else {
704 return Ok(false);
705 };
706
707 let (new_status, new_miss, new_first, new_last) = match outcome {
708 PackFetchOutcome::Found => (PACK_STATUS_ACTIVE as i64, 0, 0, 0),
709 PackFetchOutcome::Tombstoned => (PACK_STATUS_REVOKED as i64, 0, 0, 0),
710 PackFetchOutcome::CleanMiss => {
711 if status == PACK_STATUS_REVOKED as i64 {
712 (status, miss_count, first_missed_at, last_miss_counted_at)
713 } else if now - last_miss_counted_at < PACK_MISS_RATELIMIT_SECS {
714 (status, miss_count, first_missed_at, last_miss_counted_at)
715 } else {
716 let first = if miss_count == 0 { now } else { first_missed_at };
717 let count = miss_count + 1;
718 let promoted = count >= PACK_MISS_PROMOTE_COUNT
719 && now - first >= PACK_MISS_PROMOTE_SECS;
720 (
721 if promoted { PACK_STATUS_MISSING as i64 } else { status },
722 count,
723 first,
724 now,
725 )
726 }
727 }
728 PackFetchOutcome::Unreachable => unreachable!(),
729 };
730
731 let changed = new_status != status;
732 tx.execute(
733 "UPDATE emoji_packs SET status = ?2, miss_count = ?3, first_missed_at = ?4,
734 last_miss_counted_at = ?5,
735 status_changed_at = CASE WHEN status != ?2 THEN ?6 ELSE status_changed_at END
736 WHERE addr = ?1",
737 rusqlite::params![addr, new_status, new_miss, new_first, new_last, now],
738 )
739 .map_err(|e| format!("write pack health: {}", e))?;
740
741 if changed && new_status != PACK_STATUS_ACTIVE as i64 {
744 tx.execute(
745 "DELETE FROM emoji_usage WHERE kind = 1 AND url IN
746 (SELECT url FROM emoji_pack_items WHERE pack_addr = ?1)",
747 rusqlite::params![addr],
748 )
749 .map_err(|e| format!("purge dead pack usage: {}", e))?;
750 }
751
752 tx.commit().map_err(|e| format!("commit pack health: {}", e))?;
753 Ok(changed)
754}
755
756pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
757 let mut conn = crate::db::get_write_connection_guard_static()?;
758 let tx = conn.transaction()
759 .map_err(|e| format!("Failed to start tx: {}", e))?;
760
761 tx.execute("DELETE FROM emoji_pack_subscriptions", [])
762 .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
763
764 let now = std::time::SystemTime::now()
765 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
766 for (pos, addr) in addrs.iter().enumerate() {
770 tx.execute(
771 "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at, position)
772 VALUES (?1, ?2, ?3)",
773 rusqlite::params![addr, now, pos as i64],
774 ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
775 }
776
777 tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
778 Ok(())
779}
780
781pub fn load_subscriptions() -> Result<Vec<String>, String> {
782 let conn = crate::db::get_db_connection_guard_static()?;
783 let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY position ASC, subscribed_at ASC")
784 .map_err(|e| format!("prepare: {}", e))?;
785 let rows = stmt.query_map([], |row| row.get::<_, String>(0))
786 .map_err(|e| format!("query: {}", e))?;
787 let mut out = Vec::new();
788 for r in rows {
789 out.push(r.map_err(|e| format!("row: {}", e))?);
790 }
791 Ok(out)
792}
793
794pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
799 let conn = crate::db::get_db_connection_guard_static()?;
800
801 let mut pack = match conn.query_row(
802 "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at, status
803 FROM emoji_packs WHERE addr = ?1",
804 rusqlite::params![addr],
805 |row| {
806 Ok(EmojiPack {
807 id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
808 pubkey: row.get(0)?,
809 identifier: row.get(1)?,
810 title: row.get(2)?,
811 image_url: row.get(3)?,
812 description: row.get(4)?,
813 is_own: row.get::<_, i32>(5)? != 0,
814 updated_at: row.get::<_, i64>(6)? as u64,
815 emojis: Vec::new(),
816 status: row.get::<_, i64>(7)? as u8,
817 })
818 },
819 ) {
820 Ok(p) => p,
821 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
822 Err(e) => return Err(format!("query cached pack: {}", e)),
823 };
824
825 let mut stmt = conn.prepare(
826 "SELECT shortcode, url, sha256 FROM emoji_pack_items
827 WHERE pack_addr = ?1 ORDER BY position ASC"
828 ).map_err(|e| format!("prepare items: {}", e))?;
829 let rows = stmt.query_map(rusqlite::params![addr], |row| {
830 Ok(PackEmoji {
831 shortcode: row.get(0)?,
832 url: row.get(1)?,
833 sha256: row.get(2)?,
834 })
835 }).map_err(|e| format!("query items: {}", e))?;
836 for r in rows {
837 pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
838 }
839
840 Ok(Some(pack))
841}
842
843pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
848 let conn = crate::db::get_db_connection_guard_static()?;
849
850 let mut packs: HashMap<String, EmojiPack> = HashMap::new();
851 let mut order: Vec<String> = Vec::new();
852
853 {
854 let mut stmt = conn.prepare(
861 "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at, p.status
862 FROM emoji_packs p
863 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
864 ORDER BY s.position ASC"
865 ).map_err(|e| format!("prepare packs: {}", e))?;
866
867 let rows = stmt.query_map([], |row| {
868 let raw_addr: String = row.get(0)?;
872 let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
873 Ok((raw_addr, EmojiPack {
874 id,
875 pubkey: row.get(1)?,
876 identifier: row.get(2)?,
877 title: row.get(3)?,
878 image_url: row.get(4)?,
879 description: row.get(5)?,
880 is_own: row.get::<_, i32>(6)? != 0,
881 updated_at: row.get::<_, i64>(7)? as u64,
882 emojis: Vec::new(),
883 status: row.get::<_, i64>(8)? as u8,
884 }))
885 }).map_err(|e| format!("query packs: {}", e))?;
886
887 for r in rows {
888 let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
889 order.push(raw_addr.clone());
890 packs.insert(raw_addr, pack);
891 }
892 }
893
894 {
895 let mut stmt = conn.prepare(
896 "SELECT pack_addr, shortcode, url, sha256
897 FROM emoji_pack_items
898 ORDER BY pack_addr, position ASC"
899 ).map_err(|e| format!("prepare items: {}", e))?;
900
901 let rows = stmt.query_map([], |row| {
902 Ok((
903 row.get::<_, String>(0)?,
904 PackEmoji {
905 shortcode: row.get(1)?,
906 url: row.get(2)?,
907 sha256: row.get(3)?,
908 },
909 ))
910 }).map_err(|e| format!("query items: {}", e))?;
911
912 for r in rows {
913 let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
914 if let Some(p) = packs.get_mut(&addr) {
915 p.emojis.push(emoji);
916 }
917 }
918 }
919
920 Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
921}
922
923const NIP65_CACHE_TTL_SECS: u64 = 3600;
932const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
935const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
936
937#[derive(Clone)]
938struct CachedRelayList {
939 relays: Vec<RelayUrl>,
940 fetched_at: std::time::Instant,
941 empty: bool,
943 verified: bool,
947}
948
949static NIP65_CACHE: std::sync::OnceLock<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> =
950 std::sync::OnceLock::new();
951
952fn nip65_cache() -> &'static std::sync::RwLock<HashMap<PublicKey, CachedRelayList>> {
953 NIP65_CACHE.get_or_init(|| std::sync::RwLock::new(HashMap::new()))
954}
955
956fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
959 let cache = nip65_cache().read().ok()?;
960 let entry = cache.get(pubkey)?;
961 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
962 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
963 Some(entry.relays.clone())
964 } else {
965 None
966 }
967}
968
969fn cached_write_relays_verified(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
973 let cache = nip65_cache().read().ok()?;
974 let entry = cache.get(pubkey)?;
975 if !entry.verified {
976 return None;
977 }
978 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
979 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
980 Some(entry.relays.clone())
981 } else {
982 None
983 }
984}
985
986pub fn clear_nip65_cache() {
989 if let Ok(mut cache) = nip65_cache().write() {
990 cache.clear();
991 }
992}
993
994fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>, verified: bool) {
996 if let Ok(mut cache) = nip65_cache().write() {
997 let empty = relays.is_empty();
998 cache.insert(pubkey, CachedRelayList {
999 relays,
1000 fetched_at: std::time::Instant::now(),
1001 empty,
1002 verified,
1003 });
1004 }
1005}
1006
1007fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
1011 let mut relays: Vec<RelayUrl> = Vec::new();
1012 for (url, marker) in nostr_sdk::nips::nip65::extract_relay_list(ev) {
1013 match marker {
1014 None | Some(nostr_sdk::nips::nip65::RelayMetadata::Write) => {
1015 if !relays.contains(url) {
1016 relays.push(url.clone());
1017 }
1018 }
1019 Some(nostr_sdk::nips::nip65::RelayMetadata::Read) => {}
1020 }
1021 }
1022 relays
1023}
1024
1025async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
1030 if let Some(relays) = cached_write_relays(&pubkey) {
1031 return relays;
1032 }
1033
1034 let filter = Filter::new()
1035 .author(pubkey)
1036 .kind(Kind::RelayList)
1037 .limit(1);
1038 let events = match client
1039 .fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1040 .await
1041 {
1042 Ok(evs) => evs,
1043 Err(_) => {
1044 cache_write_relays(pubkey, Vec::new(), false);
1045 return Vec::new();
1046 }
1047 };
1048
1049 let relays = events.into_iter()
1050 .max_by_key(|e| e.created_at)
1051 .map(|ev| extract_write_relays(&ev))
1052 .unwrap_or_default();
1053 cache_write_relays(pubkey, relays.clone(), true);
1054 relays
1055}
1056
1057async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
1063 let uncached: Vec<PublicKey> = authors.iter()
1064 .filter(|pk| cached_write_relays(pk).is_none())
1065 .copied()
1066 .collect();
1067 if uncached.is_empty() {
1068 return;
1069 }
1070
1071 let filter = Filter::new()
1072 .authors(uncached.iter().copied())
1073 .kind(Kind::RelayList);
1074 let events = match client
1075 .fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1076 .await
1077 {
1078 Ok(evs) => evs,
1079 Err(_) => return,
1082 };
1083
1084 let mut newest: HashMap<PublicKey, Event> = HashMap::new();
1086 for ev in events {
1087 match newest.get(&ev.pubkey) {
1088 Some(existing) if existing.created_at >= ev.created_at => {}
1089 _ => { newest.insert(ev.pubkey, ev); }
1090 }
1091 }
1092 for pk in uncached {
1093 let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
1094 cache_write_relays(pk, relays, true);
1095 }
1096}
1097
1098async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
1107 let filter = Filter::new()
1108 .author(addr.pubkey)
1109 .kind(Kind::Custom(KIND_EMOJI_SET))
1110 .identifier(&addr.identifier)
1111 .limit(1);
1112 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1113 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1114
1115 match client.fetch_events(filter.clone(), timeout).await {
1118 Ok(events) => {
1119 if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
1120 if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
1121 return Some(pack);
1122 }
1123 }
1124 }
1125 Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
1126 }
1127
1128 let outbox = fetch_author_write_relays(client, addr.pubkey).await;
1134 if outbox.is_empty() {
1135 return None;
1136 }
1137 fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
1138}
1139
1140async fn fetch_pack_via_isolated_client(
1144 relays: &[RelayUrl],
1145 filter: Filter,
1146 timeout: std::time::Duration,
1147 my_pubkey_hex: Option<&str>,
1148) -> Option<EmojiPack> {
1149 let scratch = ClientBuilder::new()
1150 .opts(crate::nostr_client_options())
1151 .build();
1152 for r in relays {
1153 let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
1154 let _ = scratch.pool().add_relay(r.as_str(), opts).await;
1155 }
1156 scratch.connect().await;
1157
1158 let result = scratch.fetch_events(filter, timeout).await;
1159 scratch.shutdown().await;
1161
1162 let events = match result {
1163 Ok(events) => events,
1164 Err(e) => {
1165 crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
1166 return None;
1167 }
1168 };
1169 let event = events.into_iter().max_by_key(|e| e.created_at)?;
1170 parse_pack_from_event(&event, my_pubkey_hex)
1171}
1172
1173fn event_coord(ev: &Event) -> Option<String> {
1192 let d = first_tag(&ev.tags, &["d"])?;
1193 Some(format!("{}:{}", ev.pubkey.to_hex(), d))
1194}
1195fn addr_coord(addr: &PackAddress) -> String {
1196 format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
1197}
1198
1199fn newest_events_by_coord(
1206 events: impl IntoIterator<Item = Event>,
1207 wanted: &std::collections::HashSet<String>,
1208) -> HashMap<String, Event> {
1209 let mut newest: HashMap<String, Event> = HashMap::new();
1210 for ev in events {
1211 if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
1212 let Some(coord) = event_coord(&ev) else { continue; };
1213 if !wanted.contains(&coord) { continue; }
1214 match newest.get(&coord) {
1215 Some(existing) if existing.created_at >= ev.created_at => {}
1216 _ => { newest.insert(coord, ev); }
1217 }
1218 }
1219 newest
1220}
1221
1222fn merge_newest(acc: &mut HashMap<String, Event>, phase: HashMap<String, Event>) {
1225 for (coord, ev) in phase {
1226 match acc.get(&coord) {
1227 Some(existing) if existing.created_at >= ev.created_at => {}
1228 _ => { acc.insert(coord, ev); }
1229 }
1230 }
1231}
1232
1233fn deletion_filter(addrs: &[&PackAddress]) -> Filter {
1236 Filter::new()
1237 .authors(addrs.iter().map(|a| a.pubkey))
1238 .kind(Kind::EventDeletion)
1239 .custom_tags(
1240 SingleLetterTag::lowercase(Alphabet::A),
1241 addrs.iter().map(|a| a.to_addr_string()),
1242 )
1243}
1244
1245fn deletion_matches(ev: &Event, author: &PublicKey, raw_addr: &str) -> bool {
1248 ev.kind == Kind::EventDeletion
1249 && ev.pubkey == *author
1250 && ev.tags.iter().any(|t| {
1251 let s = t.as_slice();
1252 s.len() >= 2 && s[0] == "a" && s[1] == raw_addr
1253 })
1254}
1255
1256async fn connected_read_relays(client: &Client, read_only: bool) -> std::collections::HashSet<String> {
1261 client
1262 .relays()
1263 .await
1264 .iter()
1265 .filter(|(_, r)| r.status() == RelayStatus::Connected && (!read_only || r.flags().has_read()))
1266 .map(|(url, _)| url.to_string())
1267 .collect()
1268}
1269
1270fn absence_is_clean(
1282 home_ok: bool,
1283 live_relays: usize,
1284 author_writes: Option<&Vec<RelayUrl>>,
1285 outbox_ok: bool,
1286 outbox_live: usize,
1287) -> bool {
1288 if !home_ok || live_relays < 2 {
1289 return false;
1290 }
1291 match author_writes {
1292 None => false,
1293 Some(w) if w.is_empty() => true,
1294 Some(_) => outbox_ok && outbox_live >= 1,
1295 }
1296}
1297
1298fn classify_pack(
1301 newest_ev: Option<&Event>,
1302 newest_deletion: Option<&Event>,
1303 me: Option<&str>,
1304 absence_clean: bool,
1305) -> (PackFetchOutcome, Option<EmojiPack>) {
1306 match newest_ev {
1307 Some(ev) => match parse_pack_from_event(ev, me) {
1308 Some(pack) => {
1309 match newest_deletion {
1312 Some(del) if del.created_at > ev.created_at => {
1313 (PackFetchOutcome::Tombstoned, None)
1314 }
1315 _ => (PackFetchOutcome::Found, Some(pack)),
1316 }
1317 }
1318 None => {
1319 let has_emoji_tags = ev.tags.iter().any(|t| {
1324 t.as_slice().first().map(|k| k == "emoji").unwrap_or(false)
1325 });
1326 if has_emoji_tags {
1327 (PackFetchOutcome::Unreachable, None)
1328 } else {
1329 (PackFetchOutcome::Tombstoned, None)
1330 }
1331 }
1332 },
1333 None if newest_deletion.is_some() => (PackFetchOutcome::Tombstoned, None),
1334 None if absence_clean => (PackFetchOutcome::CleanMiss, None),
1335 None => (PackFetchOutcome::Unreachable, None),
1336 }
1337}
1338
1339struct PackSweep {
1342 packs: Vec<EmojiPack>,
1343 outcomes: HashMap<String, PackFetchOutcome>,
1345}
1346
1347async fn sweep_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> PackSweep {
1353 if addrs.is_empty() {
1354 return PackSweep { packs: Vec::new(), outcomes: HashMap::new() };
1355 }
1356 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1357 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1358 let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1359 let all_refs: Vec<&PackAddress> = addrs.iter().collect();
1360
1361 let home_before = connected_read_relays(client, true).await;
1368 let home_filter = Filter::new()
1369 .authors(addrs.iter().map(|a| a.pubkey))
1370 .kind(Kind::Custom(KIND_EMOJI_SET))
1371 .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1372 let mut newest: HashMap<String, Event> = HashMap::new();
1373 let mut deletions: Vec<Event> = Vec::new();
1374 let mut home_ok = false;
1375 match client.fetch_events(home_filter, timeout).await {
1376 Ok(events) => {
1377 home_ok = true;
1378 merge_newest(&mut newest, newest_events_by_coord(events, &wanted));
1379 }
1380 Err(e) => {
1381 crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1382 }
1383 }
1384 if home_ok {
1385 match client.fetch_events(deletion_filter(&all_refs), timeout).await {
1386 Ok(events) => deletions.extend(events),
1387 Err(e) => crate::log_warn!("[EmojiPacks] home deletion fetch failed: {}", e),
1388 }
1389 }
1390 let home_after = connected_read_relays(client, true).await;
1391 let mut live_relays: std::collections::HashSet<String> =
1392 home_before.intersection(&home_after).cloned().collect();
1393
1394 let misses: Vec<&PackAddress> = addrs.iter()
1398 .filter(|a| !newest.contains_key(&addr_coord(a)))
1399 .collect();
1400 let mut outbox_ok = false;
1401 let mut outbox_live = 0usize;
1402 if !misses.is_empty() {
1403 let miss_authors: Vec<PublicKey> = {
1404 let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1405 v.sort(); v.dedup();
1406 v
1407 };
1408 prefetch_author_write_relays(client, &miss_authors).await;
1411 let mut outbox: Vec<RelayUrl> = Vec::new();
1412 for pk in &miss_authors {
1413 for r in cached_write_relays(pk).unwrap_or_default() {
1414 if !outbox.contains(&r) { outbox.push(r); }
1415 }
1416 }
1417 if !outbox.is_empty() {
1418 let miss_filter = Filter::new()
1419 .authors(misses.iter().map(|a| a.pubkey))
1420 .kind(Kind::Custom(KIND_EMOJI_SET))
1421 .identifiers(misses.iter().map(|a| a.identifier.clone()));
1422 let wanted_misses: std::collections::HashSet<String> =
1423 misses.iter().map(|a| addr_coord(a)).collect();
1424 if let Some((events, dels, live)) = sweep_via_isolated_client(
1425 &outbox, miss_filter, deletion_filter(&misses), timeout,
1426 ).await {
1427 outbox_ok = true;
1428 outbox_live = live.len();
1429 live_relays.extend(live);
1430 merge_newest(&mut newest, newest_events_by_coord(events, &wanted_misses));
1431 deletions.extend(dels);
1432 }
1433 }
1434 }
1435
1436 let mut packs: Vec<EmojiPack> = Vec::new();
1438 let mut outcomes: HashMap<String, PackFetchOutcome> = HashMap::new();
1439 for addr in addrs {
1440 let coord = addr_coord(addr);
1441 let raw_addr = addr.to_addr_string();
1442 let newest_deletion = deletions.iter()
1443 .filter(|d| deletion_matches(d, &addr.pubkey, &raw_addr))
1444 .max_by_key(|d| d.created_at);
1445 let author_writes = cached_write_relays_verified(&addr.pubkey);
1446 let clean = absence_is_clean(
1447 home_ok,
1448 live_relays.len(),
1449 author_writes.as_ref(),
1450 outbox_ok,
1451 outbox_live,
1452 );
1453 let (outcome, pack) = classify_pack(
1454 newest.get(&coord),
1455 newest_deletion,
1456 me.as_deref(),
1457 clean,
1458 );
1459 if let Some(p) = pack {
1460 packs.push(p);
1461 }
1462 outcomes.insert(raw_addr, outcome);
1463 }
1464
1465 PackSweep { packs, outcomes }
1466}
1467
1468async fn sweep_via_isolated_client(
1475 relays: &[RelayUrl],
1476 pack_filter: Filter,
1477 del_filter: Filter,
1478 timeout: std::time::Duration,
1479) -> Option<(Vec<Event>, Vec<Event>, std::collections::HashSet<String>)> {
1480 let scratch = ClientBuilder::new()
1481 .opts(crate::nostr_client_options())
1482 .build();
1483 for r in relays {
1484 let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
1485 let _ = scratch.pool().add_relay(r.as_str(), opts).await;
1486 }
1487 scratch.connect().await;
1488 let connect_deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
1493 let before = loop {
1494 let connected = connected_read_relays(&scratch, false).await;
1495 if !connected.is_empty() || std::time::Instant::now() >= connect_deadline {
1496 break connected;
1497 }
1498 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1499 };
1500 let result = scratch.fetch_events(pack_filter, timeout).await;
1501 let dels = match &result {
1502 Ok(_) => scratch.fetch_events(del_filter, timeout).await.ok(),
1503 Err(_) => None,
1504 };
1505 let after = connected_read_relays(&scratch, false).await;
1506 scratch.shutdown().await;
1507
1508 match result {
1509 Ok(events) => Some((
1510 events.into_iter().collect(),
1511 dels.map(|d| d.into_iter().collect()).unwrap_or_default(),
1512 before.intersection(&after).cloned().collect(),
1513 )),
1514 Err(e) => {
1515 crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1516 None
1517 }
1518 }
1519}
1520
1521pub async fn fetch_subscribed_packs(
1530 client: &Client,
1531 my_pubkey: PublicKey,
1532 session: crate::state::SessionGuard,
1533) -> Result<Vec<EmojiPack>, String> {
1534 let list_filter = Filter::new()
1535 .author(my_pubkey)
1536 .kind(Kind::Custom(KIND_EMOJI_LIST))
1537 .limit(1);
1538
1539 let list_events = client
1540 .fetch_events(list_filter, std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1541 .await
1542 .map_err(|e| format!("fetch kind 10030: {}", e))?;
1543
1544 if !session.is_valid() {
1545 return Ok(Vec::new());
1546 }
1547
1548 let local_addrs = || -> Vec<PackAddress> {
1556 load_subscriptions()
1557 .unwrap_or_default()
1558 .into_iter()
1559 .filter_map(|s| parse_pack_address(&s).ok())
1560 .collect()
1561 };
1562 let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1563 .ok()
1564 .flatten()
1565 .and_then(|s| s.parse().ok())
1566 .unwrap_or(0);
1567
1568 let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1569 let mut fetched_anchor: Option<String> = None;
1572 let addrs: Vec<PackAddress> = match list_event {
1573 Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1574 crate::log_debug!(
1575 "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1576 ev.created_at.as_secs(), our_last_publish,
1577 );
1578 local_addrs()
1579 }
1580 Some(ev) => {
1581 let (a, anchor) = decrypt_subscribed_addresses_with_anchor(client, &my_pubkey, &ev).await;
1582 fetched_anchor = anchor;
1583 a
1584 }
1585 None => {
1586 crate::log_debug!(
1587 "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1588 );
1589 local_addrs()
1590 }
1591 };
1592
1593 let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1594
1595 let PackSweep { packs: fresh, outcomes } = sweep_packs_from_relays(client, &addrs).await;
1602 let tombstoned = outcomes.values().filter(|o| matches!(o, PackFetchOutcome::Tombstoned)).count();
1603 let unresolved = addrs.len().saturating_sub(fresh.len()).saturating_sub(tombstoned);
1604 if unresolved > 0 {
1605 crate::log_warn!(
1606 "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1607 unresolved,
1608 );
1609 }
1610 if tombstoned > 0 {
1611 crate::log_info!("[EmojiPacks] {} subscribed pack(s) deleted by their creator", tombstoned);
1612 }
1613
1614 if !session.is_valid() {
1615 return Ok(fresh);
1616 }
1617
1618 let me_hex = my_pubkey.to_hex();
1624 let now = std::time::SystemTime::now()
1625 .duration_since(std::time::UNIX_EPOCH)
1626 .map(|d| d.as_secs() as i64)
1627 .unwrap_or(0);
1628 let mut health_changed = false;
1629 for addr in &addrs {
1630 if addr.pubkey.to_hex() == me_hex { continue; }
1631 let raw = addr.to_addr_string();
1632 let Some(outcome) = outcomes.get(&raw) else { continue; };
1633 match apply_pack_health(&raw, outcome, now) {
1634 Ok(true) => {
1635 health_changed = true;
1636 crate::log_info!(
1637 "[EmojiPacks] pack `{}` health changed ({:?})",
1638 addr.identifier, outcome,
1639 );
1640 }
1641 Ok(false) => {}
1642 Err(e) => crate::log_warn!("[EmojiPacks] health update for `{}` failed: {}", addr.identifier, e),
1643 }
1644 }
1645 for pack in &fresh {
1646 if let Err(e) = save_pack(pack) {
1647 crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1648 }
1649 }
1650 let list_changed = load_subscriptions().unwrap_or_default() != addr_strings
1655 || fetched_anchor.as_deref().map_or(false, |a| a != load_theme_slot_anchor());
1656
1657 if let Err(e) = save_subscriptions(&addr_strings) {
1661 crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1662 }
1663 if let Some(anchor) = fetched_anchor {
1666 if let Err(e) = save_theme_slot_anchor(&anchor) {
1667 crate::log_warn!("[EmojiPacks] save theme slot anchor failed: {}", e);
1668 }
1669 }
1670 if health_changed || list_changed {
1671 crate::traits::emit_event("emoji_packs_updated", &());
1672 }
1673
1674 crate::log_info!(
1675 "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1676 fresh.len(),
1677 addrs.len(),
1678 if unresolved > 0 {
1679 format!(" ({} via cache)", unresolved)
1680 } else {
1681 String::new()
1682 },
1683 );
1684
1685 load_all_packs()
1688}
1689
1690pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1695 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1696 let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1697 let session = crate::state::SessionGuard::capture();
1698 fetch_subscribed_packs(&client, me, session).await
1699}
1700
1701pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1705 let addr = parse_naddr(naddr)?;
1706 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1707 fetch_pack_from_relays(&client, &addr).await
1708 .ok_or_else(|| {
1709 crate::log_debug!("[EmojiPacks] preview miss: {}:{}", addr.pubkey.to_hex(), addr.identifier);
1712 "Pack not found on any relay".to_string()
1713 })
1714}
1715
1716pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1724 let addr = parse_naddr(naddr)?;
1725 let coord = addr.to_addr_string();
1726
1727 if let Some(cached) = load_cached_pack(&coord)? {
1730 let naddr_owned = naddr.to_string();
1731 let session = crate::state::SessionGuard::capture();
1732 tokio::spawn(async move {
1733 if !session.is_valid() { return; }
1734 let Some(client) = nostr_client() else { return };
1735 if let Ok(parsed) = parse_naddr(&naddr_owned) {
1736 if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1737 if session.is_valid() && fresh.updated_at > cached.updated_at {
1738 if let Err(e) = save_pack(&fresh) {
1739 crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1740 } else {
1741 crate::traits::emit_event("emoji_packs_updated", &());
1742 }
1743 }
1744 }
1745 }
1746 });
1747 return Ok(Some(cached));
1748 }
1749
1750 let session = crate::state::SessionGuard::capture();
1752 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1753 match fetch_pack_from_relays(&client, &addr).await {
1754 Some(pack) => {
1755 if session.is_valid() {
1759 if let Err(e) = save_pack(&pack) {
1760 crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1761 }
1762 }
1763 Ok(Some(pack))
1764 }
1765 None => Ok(None),
1766 }
1767}
1768
1769const THEME_SLOT_ANCHOR_KEY: &str = "emoji_theme_slot_anchor";
1775
1776fn load_theme_slot_anchor() -> String {
1778 crate::db::settings::get_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string())
1779 .ok()
1780 .flatten()
1781 .unwrap_or_default()
1782}
1783
1784fn save_theme_slot_anchor(raw_addr: &str) -> Result<(), String> {
1786 crate::db::settings::set_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string(), raw_addr.to_string())
1787}
1788
1789fn build_inner_tags_with_marker(addrs: &[String], anchor: &str) -> Vec<Vec<String>> {
1794 let mut inner: Vec<Vec<String>> = Vec::with_capacity(addrs.len() + 1);
1795 let mut placed = false;
1796 if anchor.is_empty() {
1797 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1798 placed = true;
1799 }
1800 for addr in addrs {
1801 inner.push(vec!["a".to_string(), addr.clone()]);
1802 if !placed && addr == anchor {
1803 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1804 placed = true;
1805 }
1806 }
1807 if !placed {
1810 inner.insert(0, vec![THEME_SLOT_TOKEN.to_string()]);
1811 }
1812 inner
1813}
1814
1815pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1826 let addrs = load_subscriptions()?;
1827 let anchor = load_theme_slot_anchor();
1828 let my_pk = crate::state::my_public_key()
1829 .ok_or_else(|| "Not logged in".to_string())?;
1830
1831 let inner_tags = build_inner_tags_with_marker(&addrs, &anchor);
1832 let plaintext = serde_json::to_string(&inner_tags)
1833 .map_err(|e| format!("Serialise emoji list: {}", e))?;
1834
1835 let signer = client.signer().await
1836 .map_err(|e| format!("Signer unavailable: {}", e))?;
1837 let content = signer.nip44_encrypt(&my_pk, &plaintext).await
1838 .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1839
1840 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1841 client.send_event_builder(builder).await
1842 .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1843
1844 crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1845 Ok(())
1846}
1847
1848const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1852
1853static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1854
1855pub fn republish_emoji_list_debounced() {
1859 use std::sync::atomic::Ordering;
1860 let _ = crate::db::settings::set_sql_setting(
1866 EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1867 Timestamp::now().as_secs().to_string(),
1868 );
1869 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1870 let session = crate::state::SessionGuard::capture();
1871 tokio::spawn(async move {
1872 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1873 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1874 if !session.is_valid() { return; }
1875 let client = match nostr_client() {
1876 Some(c) => c,
1877 None => return,
1878 };
1879 if let Err(e) = publish_emoji_list(&client).await {
1880 crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1881 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1882 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1883 if !session.is_valid() { return; }
1884 if let Err(e2) = publish_emoji_list(&client).await {
1885 crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1886 }
1887 }
1888 });
1889}
1890
1891pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1895 let session = crate::state::SessionGuard::capture();
1896 let pack = fetch_pack_by_naddr(naddr).await?;
1897 if !session.is_valid() {
1898 return Err("Account swapped during fetch — aborted".to_string());
1899 }
1900
1901 let pack_addr = pack.addr();
1904 {
1905 let existing_subs = load_subscriptions()?;
1906 let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
1907 let cap = effective_max_equipped_packs();
1908 if is_new && existing_subs.len() >= cap {
1909 return Err(format!(
1910 "You can equip at most {} packs. Remove one to add another.",
1911 cap,
1912 ));
1913 }
1914 }
1915
1916 save_pack(&pack)?;
1917
1918 let mut subs = load_subscriptions()?;
1919 if !subs.iter().any(|a| a == &pack_addr) {
1920 subs.push(pack_addr.clone());
1921 }
1922 if !session.is_valid() {
1923 return Err("Account swapped before subscription save — aborted".to_string());
1924 }
1925 save_subscriptions(&subs)?;
1926
1927 republish_emoji_list_debounced();
1928 crate::traits::emit_event("emoji_packs_updated", &());
1929
1930 Ok(pack)
1931}
1932
1933fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1942 if pack.identifier.is_empty() {
1943 return Err("pack identifier required".to_string());
1944 }
1945 let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1946 .tag(Tag::custom(TagKind::custom("d"), [pack.identifier.clone()]));
1947
1948 if !pack.title.is_empty() {
1950 builder = builder
1951 .tag(Tag::custom(TagKind::custom("title"), [pack.title.clone()]))
1952 .tag(Tag::custom(TagKind::custom("name"), [pack.title.clone()]));
1953 }
1954 if !pack.image_url.is_empty() {
1955 builder = builder
1956 .tag(Tag::custom(TagKind::custom("image"), [pack.image_url.clone()]))
1957 .tag(Tag::custom(TagKind::custom("picture"), [pack.image_url.clone()]));
1958 }
1959 if !pack.description.is_empty() {
1960 builder = builder
1961 .tag(Tag::custom(TagKind::custom("description"), [pack.description.clone()]))
1962 .tag(Tag::custom(TagKind::custom("about"), [pack.description.clone()]));
1963 }
1964
1965 for e in &pack.emojis {
1966 if e.shortcode.is_empty() || e.url.is_empty() { continue; }
1967 builder = builder.tag(Tag::custom(
1968 TagKind::custom("emoji"),
1969 [e.shortcode.clone(), e.url.clone()],
1970 ));
1971 }
1972 Ok(builder)
1973}
1974
1975pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
1980 let session = crate::state::SessionGuard::capture();
1981 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1982 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1983
1984 let emoji_cap = effective_max_emojis_per_pack();
1987 if pack.emojis.len() > emoji_cap {
1988 return Err(format!(
1989 "A pack can hold at most {} emojis.",
1990 emoji_cap,
1991 ));
1992 }
1993
1994 let mut to_save = pack.clone();
1997 to_save.pubkey = my_pk.to_hex();
1998 to_save.is_own = true;
1999 let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
2000 to_save.id = naddr_from_addr(&raw_addr)?;
2001 to_save.updated_at = std::time::SystemTime::now()
2002 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
2003
2004 {
2007 let existing_subs = load_subscriptions()?;
2008 let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
2009 let cap = effective_max_equipped_packs();
2010 if is_new && existing_subs.len() >= cap {
2011 return Err(format!(
2012 "You can equip at most {} packs. Remove one to add another.",
2013 cap,
2014 ));
2015 }
2016 }
2017
2018 let builder = build_pack_event(&to_save)?;
2019 client.send_event_builder(builder).await
2020 .map_err(|e| format!("publish kind 30030: {}", e))?;
2021
2022 if !session.is_valid() {
2023 return Err("Account swapped during publish — local state untouched".to_string());
2024 }
2025
2026 save_pack(&to_save)?;
2027
2028 let mut subs = load_subscriptions()?;
2031 if !subs.iter().any(|a| a == &raw_addr) {
2032 subs.push(raw_addr.clone());
2033 if !session.is_valid() {
2034 return Err("Account swapped before subscription save — aborted".to_string());
2035 }
2036 save_subscriptions(&subs)?;
2037 republish_emoji_list_debounced();
2038 }
2039
2040 crate::traits::emit_event("emoji_packs_updated", &());
2041 crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
2042 to_save.identifier, to_save.emojis.len());
2043
2044 Ok(to_save)
2045}
2046
2047pub async fn delete_own_pack(id: &str) -> Result<(), String> {
2051 let session = crate::state::SessionGuard::capture();
2052 let parsed = parse_naddr(id)?;
2053 let raw_addr = parsed.to_addr_string();
2054 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2055 if parsed.pubkey != my_pk {
2056 return Err("Cannot delete a pack you don't own".to_string());
2057 }
2058 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2059
2060 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2061 .tag(Tag::custom(TagKind::custom("d"), [parsed.identifier.clone()]));
2062 client.send_event_builder(builder).await
2063 .map_err(|e| format!("publish empty kind 30030: {}", e))?;
2064
2065 if !session.is_valid() {
2066 return Err("Account swapped during delete — local state untouched".to_string());
2067 }
2068
2069 {
2073 let mut conn = crate::db::get_write_connection_guard_static()?;
2074 let tx = conn.transaction()
2075 .map_err(|e| format!("begin delete tx: {}", e))?;
2076 tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
2077 rusqlite::params![raw_addr])
2078 .map_err(|e| format!("drop subscription: {}", e))?;
2079 tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
2080 rusqlite::params![raw_addr])
2081 .map_err(|e| format!("drop pack row: {}", e))?;
2082 tx.commit()
2083 .map_err(|e| format!("commit delete tx: {}", e))?;
2084 }
2085
2086 republish_emoji_list_debounced();
2087 crate::traits::emit_event("emoji_packs_updated", &());
2088 crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
2089 Ok(())
2090}
2091
2092pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
2096 let session = crate::state::SessionGuard::capture();
2097 let raw_addr = parse_naddr(id)?.to_addr_string();
2098 let mut subs = load_subscriptions()?;
2099 let before = subs.len();
2100 subs.retain(|a| a != &raw_addr);
2101 if subs.len() == before {
2102 return Ok(()); }
2104 if !session.is_valid() {
2105 return Err("Account swapped before unsubscribe save — aborted".to_string());
2106 }
2107 save_subscriptions(&subs)?;
2108 republish_emoji_list_debounced();
2109 crate::traits::emit_event("emoji_packs_updated", &());
2110 Ok(())
2111}
2112
2113pub fn reorder_emoji_packs(ordered_ids: Vec<String>) -> Result<(), String> {
2122 let session = crate::state::SessionGuard::capture();
2123
2124 let mut real_addrs: Vec<String> = Vec::with_capacity(ordered_ids.len());
2125 let mut anchor = String::new();
2126 let mut marker_present = false;
2127 for id in &ordered_ids {
2128 if id == THEME_SLOT_TOKEN {
2129 marker_present = true;
2130 anchor = real_addrs.last().cloned().unwrap_or_default();
2131 } else {
2132 real_addrs.push(parse_naddr(id)?.to_addr_string());
2133 }
2134 }
2135
2136 if !session.is_valid() {
2137 return Err("Account swapped during reorder — aborted".to_string());
2138 }
2139 save_subscriptions(&real_addrs)?;
2140 if marker_present {
2144 save_theme_slot_anchor(&anchor)?;
2145 }
2146 republish_emoji_list_debounced();
2147 crate::traits::emit_event("emoji_packs_updated", &());
2148 Ok(())
2149}
2150
2151pub fn get_theme_slot_anchor() -> Result<String, String> {
2155 let raw = load_theme_slot_anchor();
2156 if raw.is_empty() {
2157 return Ok(String::new());
2158 }
2159 Ok(naddr_from_addr(&raw).unwrap_or_default())
2160}
2161
2162#[cfg(test)]
2167mod tests {
2168 use super::*;
2169
2170 fn keys() -> Keys {
2171 Keys::generate()
2172 }
2173
2174 fn build_pack_event(
2175 k: &Keys,
2176 d: &str,
2177 title_tag: Option<(&str, &str)>,
2178 image_tag: Option<(&str, &str)>,
2179 desc_tag: Option<(&str, &str)>,
2180 emojis: &[(&str, &str)],
2181 ) -> Event {
2182 let mut tags: Vec<Tag> = Vec::new();
2183 tags.push(Tag::custom(TagKind::custom("d"), [d]));
2184 if let Some((key, val)) = title_tag {
2185 tags.push(Tag::custom(TagKind::custom(key), [val]));
2186 }
2187 if let Some((key, val)) = image_tag {
2188 tags.push(Tag::custom(TagKind::custom(key), [val]));
2189 }
2190 if let Some((key, val)) = desc_tag {
2191 tags.push(Tag::custom(TagKind::custom(key), [val]));
2192 }
2193 for (code, url) in emojis {
2194 tags.push(Tag::custom(TagKind::custom("emoji"), [*code, *url]));
2195 }
2196 EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2197 .tags(tags)
2198 .sign_with_keys(k)
2199 .unwrap()
2200 }
2201
2202 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2203 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2204 crate::db::close_database();
2205 crate::db::clear_id_caches();
2208 let tmp = tempfile::tempdir().unwrap();
2209 let account = keys().public_key().to_bech32().unwrap();
2210 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2211 crate::db::set_app_data_dir(tmp.path().to_path_buf());
2212 crate::db::set_current_account(account.clone()).unwrap();
2213 crate::db::init_database(&account).unwrap();
2214 (tmp, guard)
2215 }
2216
2217 fn seed_pack(k: &Keys, d: &str, emojis: &[(&str, &str)]) -> String {
2219 let ev = build_pack_event(k, d, None, None, None, emojis);
2220 let pack = parse_pack_from_event(&ev, None).unwrap();
2221 save_pack(&pack).unwrap();
2222 pack.addr()
2223 }
2224
2225 fn seed_usage(url: &str) {
2226 let conn = crate::db::get_write_connection_guard_static().unwrap();
2227 conn.execute(
2228 "INSERT INTO emoji_usage (kind, id, url, score, last_used) VALUES (1, ?1, ?1, 1.0, 0)",
2229 rusqlite::params![url],
2230 ).unwrap();
2231 }
2232
2233 fn usage_count() -> i64 {
2234 let conn = crate::db::get_db_connection_guard_static().unwrap();
2235 conn.query_row("SELECT COUNT(*) FROM emoji_usage", [], |r| r.get(0)).unwrap()
2236 }
2237
2238 fn pack_status(addr: &str) -> u8 {
2239 load_cached_pack(addr).unwrap().unwrap().status
2240 }
2241
2242 fn build_deletion_event(k: &Keys, addr: &str) -> Event {
2243 EventBuilder::new(Kind::EventDeletion, "")
2244 .tag(Tag::custom(TagKind::custom("a"), [addr]))
2245 .sign_with_keys(k)
2246 .unwrap()
2247 }
2248
2249 #[test]
2250 fn classify_verdict_table() {
2251 let k = keys();
2252 let live = build_pack_event(&k, "p", None, None, None, &[("a", "https://e.x/a.png")]);
2253 let empty = build_pack_event(&k, "p", None, None, None, &[]);
2254 let addr = format!("30030:{}:p", k.public_key().to_hex());
2255 let del = build_deletion_event(&k, &addr);
2256
2257 let (o, p) = classify_pack(Some(&live), None, None, false);
2259 assert_eq!(o, PackFetchOutcome::Found);
2260 assert!(p.is_some());
2261
2262 let (o, _) = classify_pack(Some(&empty), None, None, true);
2264 assert_eq!(o, PackFetchOutcome::Tombstoned);
2265
2266 let (o, _) = classify_pack(None, Some(&del), None, true);
2268 assert_eq!(o, PackFetchOutcome::Tombstoned);
2269
2270 let ts = Timestamp::from_secs(1_700_000_000);
2273 let live_pinned = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2274 .tags(vec![
2275 Tag::custom(TagKind::custom("d"), ["p"]),
2276 Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
2277 ])
2278 .custom_created_at(ts)
2279 .sign_with_keys(&k)
2280 .unwrap();
2281 let del_at = |secs: u64| {
2282 EventBuilder::new(Kind::EventDeletion, "")
2283 .tag(Tag::custom(TagKind::custom("a"), [addr.as_str()]))
2284 .custom_created_at(Timestamp::from_secs(secs))
2285 .sign_with_keys(&k)
2286 .unwrap()
2287 };
2288
2289 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_000)), None, false);
2292 assert_eq!(o, PackFetchOutcome::Found);
2293 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_001)), None, false);
2295 assert_eq!(o, PackFetchOutcome::Tombstoned);
2296 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_699_999_999)), None, false);
2298 assert_eq!(o, PackFetchOutcome::Found);
2299
2300 let (o, _) = classify_pack(None, None, None, true);
2302 assert_eq!(o, PackFetchOutcome::CleanMiss);
2303 let (o, _) = classify_pack(None, None, None, false);
2304 assert_eq!(o, PackFetchOutcome::Unreachable);
2305 }
2306
2307 #[test]
2308 fn classify_invalid_emojis_is_not_a_tombstone() {
2309 let k = keys();
2312 let ev = build_pack_event(&k, "weird", None, None, None, &[("bad!code", "https://e.x/a.png")]);
2313 assert!(parse_pack_from_event(&ev, None).is_none(), "precondition: unparseable");
2314 let (o, _) = classify_pack(Some(&ev), None, None, true);
2315 assert_eq!(o, PackFetchOutcome::Unreachable);
2316 }
2317
2318 #[test]
2319 fn absence_gate_table() {
2320 let some_writes = vec![RelayUrl::parse("wss://relay.author.example").unwrap()];
2321 let no_writes: Vec<RelayUrl> = Vec::new();
2322
2323 assert!(!absence_is_clean(false, 5, Some(&no_writes), true, 1));
2325 assert!(!absence_is_clean(true, 1, Some(&no_writes), true, 1));
2326 assert!(!absence_is_clean(true, 3, None, true, 1));
2329 assert!(absence_is_clean(true, 2, Some(&no_writes), false, 0));
2331 assert!(!absence_is_clean(true, 3, Some(&some_writes), false, 0));
2333 assert!(!absence_is_clean(true, 3, Some(&some_writes), true, 0));
2334 assert!(absence_is_clean(true, 3, Some(&some_writes), true, 1));
2335 }
2336
2337 #[test]
2338 fn deletion_matching_is_author_bound() {
2339 let author = keys();
2340 let stranger = keys();
2341 let addr = format!("30030:{}:p", author.public_key().to_hex());
2342 let forged = build_deletion_event(&stranger, &addr);
2344 assert!(!deletion_matches(&forged, &author.public_key(), &addr));
2345 let real = build_deletion_event(&author, &addr);
2346 assert!(deletion_matches(&real, &author.public_key(), &addr));
2347 }
2348
2349 #[test]
2350 fn tombstone_revokes_immediately_and_purges_frecency() {
2351 let (_tmp, _guard) = init_test_db();
2352 let k = keys();
2353 let addr = seed_pack(&k, "deadpack", &[("boom", "https://e.x/boom.png")]);
2354 seed_usage("https://e.x/boom.png");
2355 seed_usage("https://e.x/unrelated.png");
2356
2357 let changed = apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2358 assert!(changed, "tombstone must flip status in one sweep");
2359 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2360 assert_eq!(usage_count(), 1, "only the dead pack's usage rows purge");
2361 }
2362
2363 #[test]
2364 fn found_self_heals_from_revoked() {
2365 let (_tmp, _guard) = init_test_db();
2366 let k = keys();
2367 let addr = seed_pack(&k, "phoenix", &[("rise", "https://e.x/r.png")]);
2368 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2369 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2370
2371 let changed = apply_pack_health(&addr, &PackFetchOutcome::Found, 2_000).unwrap();
2372 assert!(changed);
2373 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "a republished pack revives");
2374 }
2375
2376 #[test]
2377 fn clean_miss_gauntlet_rate_limit_and_promotion() {
2378 let (_tmp, _guard) = init_test_db();
2379 let k = keys();
2380 let addr = seed_pack(&k, "fading", &[("bye", "https://e.x/bye.png")]);
2381 let t0: i64 = 1_000_000;
2382 let h = 3_600;
2383
2384 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0).unwrap());
2386 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + h).unwrap());
2387 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 13 * h).unwrap());
2389 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 26 * h).unwrap());
2390 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "count alone must not promote");
2391 let changed = apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 49 * h).unwrap();
2393 assert!(changed);
2394 assert_eq!(pack_status(&addr), PACK_STATUS_MISSING);
2395 }
2396
2397 #[test]
2398 fn unreachable_never_moves_counters() {
2399 let (_tmp, _guard) = init_test_db();
2400 let k = keys();
2401 let addr = seed_pack(&k, "offline", &[("zz", "https://e.x/z.png")]);
2402 for t in [1_000i64, 200_000, 400_000, 800_000] {
2403 assert!(!apply_pack_health(&addr, &PackFetchOutcome::Unreachable, t).unwrap());
2404 }
2405 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE);
2406 let conn = crate::db::get_db_connection_guard_static().unwrap();
2407 let miss: i64 = conn.query_row(
2408 "SELECT miss_count FROM emoji_packs WHERE addr = ?1",
2409 rusqlite::params![addr], |r| r.get(0),
2410 ).unwrap();
2411 assert_eq!(miss, 0, "offline sweeps must not accrue misses");
2412 }
2413
2414 #[test]
2415 fn revoked_ignores_clean_misses() {
2416 let (_tmp, _guard) = init_test_db();
2417 let k = keys();
2418 let addr = seed_pack(&k, "sealed", &[("x", "https://e.x/x.png")]);
2419 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2420 for t in [100_000i64, 300_000, 600_000] {
2421 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t).unwrap());
2422 }
2423 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED, "misses can't downgrade a tombstone verdict");
2424 }
2425
2426 #[test]
2427 fn empty_pack_event_is_the_tombstone_shape() {
2428 let k = keys();
2432 let ev = build_pack_event(&k, "gone", None, None, None, &[]);
2433 assert!(parse_pack_from_event(&ev, None).is_none());
2434 }
2435
2436 #[test]
2437 fn parse_pack_reads_nip51_spec_tags() {
2438 let k = keys();
2439 let ev = build_pack_event(
2440 &k, "myPack",
2441 Some(("title", "Spec Pack")),
2442 Some(("image", "https://example.com/p.png")),
2443 Some(("description", "specd")),
2444 &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
2445 );
2446 let pack = parse_pack_from_event(&ev, None).unwrap();
2447 assert_eq!(pack.identifier, "myPack");
2448 assert_eq!(pack.title, "Spec Pack");
2449 assert_eq!(pack.image_url, "https://example.com/p.png");
2450 assert_eq!(pack.description, "specd");
2451 assert_eq!(pack.emojis.len(), 2);
2452 assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
2453 }
2454
2455 #[test]
2456 fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
2457 let k = keys();
2458 let ev = build_pack_event(
2459 &k, "ditto",
2460 Some(("name", "Ditto Pack")),
2461 Some(("picture", "https://example.com/d.png")),
2462 Some(("about", "ditto-style")),
2463 &[("yes", "https://e.x/y.png")],
2464 );
2465 let pack = parse_pack_from_event(&ev, None).unwrap();
2466 assert_eq!(pack.title, "Ditto Pack");
2467 assert_eq!(pack.image_url, "https://example.com/d.png");
2468 assert_eq!(pack.description, "ditto-style");
2469 }
2470
2471 #[test]
2472 fn parse_pack_prefers_spec_tags_over_ditto() {
2473 let k = keys();
2474 let mut tags: Vec<Tag> = vec![
2475 Tag::custom(TagKind::custom("d"), ["both"]),
2476 Tag::custom(TagKind::custom("title"), ["SpecTitle"]),
2477 Tag::custom(TagKind::custom("name"), ["DittoName"]),
2478 Tag::custom(TagKind::custom("image"), ["spec.png"]),
2479 Tag::custom(TagKind::custom("picture"), ["ditto.png"]),
2480 Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
2481 ];
2482 tags.extend(std::iter::empty());
2483 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2484 .tags(tags).sign_with_keys(&k).unwrap();
2485 let pack = parse_pack_from_event(&ev, None).unwrap();
2486 assert_eq!(pack.title, "SpecTitle");
2487 assert_eq!(pack.image_url, "spec.png");
2488 }
2489
2490 #[test]
2491 fn parse_pack_returns_none_without_d_tag() {
2492 let k = keys();
2493 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2494 .tags(vec![
2495 Tag::custom(TagKind::custom("title"), ["No D"]),
2496 Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
2497 ])
2498 .sign_with_keys(&k).unwrap();
2499 assert!(parse_pack_from_event(&ev, None).is_none());
2500 }
2501
2502 #[test]
2503 fn parse_pack_returns_none_when_no_valid_emojis() {
2504 let k = keys();
2505 let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
2506 assert!(parse_pack_from_event(&ev, None).is_none());
2507 }
2508
2509 #[test]
2510 fn parse_pack_rejects_invalid_shortcodes() {
2511 let k = keys();
2512 let ev = build_pack_event(
2513 &k, "mix", Some(("title", "Mix")), None, None,
2514 &[("ok_name", "https://e.x/a.png"),
2515 ("bad name", "https://e.x/b.png"),
2516 ("colons:no", "https://e.x/c.png"),
2517 ("", "https://e.x/d.png"),
2518 ("dash-ok", "https://e.x/e.png")],
2519 );
2520 let pack = parse_pack_from_event(&ev, None).unwrap();
2521 let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
2522 assert_eq!(codes, vec!["ok_name", "dash-ok"]);
2523 }
2524
2525 #[test]
2526 fn parse_pack_dedupes_shortcodes_first_wins() {
2527 let k = keys();
2528 let ev = build_pack_event(
2529 &k, "dup", Some(("title", "Dup")), None, None,
2530 &[("smile", "https://e.x/first.png"),
2531 ("smile", "https://e.x/second.png")],
2532 );
2533 let pack = parse_pack_from_event(&ev, None).unwrap();
2534 assert_eq!(pack.emojis.len(), 1);
2535 assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
2536 }
2537
2538 #[test]
2539 fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
2540 let k = keys();
2541 let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
2542 &[("a", "https://e.x/a.png")]);
2543 let my_hex = k.public_key().to_hex();
2544 let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
2545 assert!(pack_mine.is_own);
2546
2547 let stranger = keys().public_key().to_hex();
2548 let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
2549 assert!(!pack_other.is_own);
2550 }
2551
2552 #[test]
2553 fn pack_address_to_string_round_trips() {
2554 let k = keys();
2555 let hex = k.public_key().to_hex();
2556 let addr = PackAddress {
2557 kind: 30030,
2558 pubkey: k.public_key(),
2559 identifier: "myPack".to_string(),
2560 };
2561 assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
2562 let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
2563 assert_eq!(parsed, addr);
2564 }
2565
2566 #[test]
2567 fn parse_naddr_round_trips_kind_30030_coordinate() {
2568 let k = keys();
2571 let coord = nostr_sdk::nips::nip01::Coordinate {
2572 kind: Kind::Custom(30030),
2573 public_key: k.public_key(),
2574 identifier: "trip".to_string(),
2575 };
2576 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
2577 coordinate: coord,
2578 relays: Vec::new(),
2579 };
2580 let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2581 let parsed = parse_naddr(&naddr).unwrap();
2582 assert_eq!(parsed.kind, 30030);
2583 assert_eq!(parsed.pubkey, k.public_key());
2584 assert_eq!(parsed.identifier, "trip");
2585 }
2586
2587 #[test]
2588 fn parse_naddr_rejects_non_30030_kinds() {
2589 let k = keys();
2590 let coord = nostr_sdk::nips::nip01::Coordinate {
2591 kind: Kind::Custom(30023), public_key: k.public_key(),
2593 identifier: "essay".to_string(),
2594 };
2595 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
2596 coordinate: coord,
2597 relays: Vec::new(),
2598 };
2599 let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2600 let err = parse_naddr(&naddr).unwrap_err();
2601 assert!(err.contains("expected kind 30030"),
2602 "expected kind-rejection error, got: {}", err);
2603 }
2604
2605 #[test]
2606 fn parse_naddr_strips_nostr_uri_prefix() {
2607 let k = keys();
2608 let coord = nostr_sdk::nips::nip01::Coordinate {
2609 kind: Kind::Custom(30030),
2610 public_key: k.public_key(),
2611 identifier: "prefixed".to_string(),
2612 };
2613 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
2614 coordinate: coord,
2615 relays: Vec::new(),
2616 };
2617 let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2618 let with_prefix = format!("nostr:{}", naddr);
2619 let parsed = parse_naddr(&with_prefix).unwrap();
2620 assert_eq!(parsed.identifier, "prefixed");
2621 }
2622
2623 #[test]
2624 fn parse_naddr_rejects_garbage_input() {
2625 assert!(parse_naddr("not an naddr").is_err());
2626 assert!(parse_naddr("naddr1invalid").is_err());
2627 assert!(parse_naddr("").is_err());
2628 }
2629
2630 #[test]
2631 fn parse_pack_address_round_trips_valid_input() {
2632 let k = keys();
2633 let hex = k.public_key().to_hex();
2634 let addr = format!("30030:{}:myId", hex);
2635 let parsed = parse_pack_address(&addr).unwrap();
2636 assert_eq!(parsed.kind, 30030);
2637 assert_eq!(parsed.pubkey, k.public_key());
2638 assert_eq!(parsed.identifier, "myId");
2639 }
2640
2641 #[test]
2642 fn parse_pack_address_rejects_wrong_kind() {
2643 let hex = keys().public_key().to_hex();
2644 let addr = format!("10030:{}:x", hex);
2645 assert!(parse_pack_address(&addr).is_err());
2646 }
2647
2648 #[test]
2649 fn parse_pack_address_rejects_malformed_pubkey() {
2650 let addr = "30030:not-hex:x".to_string();
2651 assert!(parse_pack_address(&addr).is_err());
2652 }
2653
2654 #[test]
2655 fn parse_pack_address_preserves_colons_in_identifier() {
2656 let hex = keys().public_key().to_hex();
2658 let addr = format!("30030:{}:id:with:colons", hex);
2659 let parsed = parse_pack_address(&addr).unwrap();
2660 assert_eq!(parsed.identifier, "id:with:colons");
2661 }
2662
2663 #[test]
2664 fn parse_inner_tag_list_extracts_valid_a_tags() {
2665 let hex_a = keys().public_key().to_hex();
2669 let hex_b = keys().public_key().to_hex();
2670 let plaintext = format!(
2671 r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
2672 a = hex_a,
2673 b = hex_b,
2674 );
2675 let addrs = parse_inner_tag_list(&plaintext);
2676 assert_eq!(addrs.len(), 2);
2677 assert_eq!(addrs[0].identifier, "packA");
2678 assert_eq!(addrs[1].identifier, "packB");
2679 }
2680
2681 #[test]
2682 fn parse_inner_tag_list_returns_empty_on_malformed_json() {
2683 assert!(parse_inner_tag_list("not json").is_empty());
2684 assert!(parse_inner_tag_list("").is_empty());
2685 }
2686
2687 #[test]
2688 fn theme_slot_marker_round_trips_through_publish_and_parse() {
2689 let a = keys().public_key().to_hex();
2692 let b = keys().public_key().to_hex();
2693 let c = keys().public_key().to_hex();
2694 let addr_a = format!("30030:{}:packA", a);
2695 let addr_b = format!("30030:{}:packB", b);
2696 let addr_c = format!("30030:{}:packC", c);
2697 let addrs = vec![addr_a.clone(), addr_b.clone(), addr_c.clone()];
2698
2699 let inner = build_inner_tags_with_marker(&addrs, &addr_b);
2700 let marker_count = inner.iter()
2701 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2702 .count();
2703 assert_eq!(marker_count, 1, "exactly one marker is emitted");
2704
2705 let plaintext = serde_json::to_string(&inner).unwrap();
2706 let (parsed, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
2707 assert_eq!(parsed, addrs, "addr order survives the round trip");
2708 assert_eq!(anchor.as_deref(), Some(addr_b.as_str()), "anchor is the 2nd pack");
2709
2710 let old = format!(r#"[["a","{}"],["a","{}"]]"#, addr_a, addr_b);
2712 let (old_addrs, old_anchor) = parse_inner_tag_list_with_anchor(&old);
2713 assert_eq!(old_addrs, vec![addr_a.clone(), addr_b.clone()]);
2714 assert_eq!(old_anchor, None, "a list without the marker has no anchor");
2715
2716 let top = build_inner_tags_with_marker(&addrs, "");
2718 assert_eq!(top[0], vec![THEME_SLOT_TOKEN.to_string()]);
2719 let (_, top_anchor) =
2720 parse_inner_tag_list_with_anchor(&serde_json::to_string(&top).unwrap());
2721 assert_eq!(top_anchor.as_deref(), Some(""), "top slot = empty anchor");
2722
2723 let gone = format!("30030:{}:ghost", keys().public_key().to_hex());
2725 let fallback = build_inner_tags_with_marker(&addrs, &gone);
2726 assert_eq!(fallback[0], vec![THEME_SLOT_TOKEN.to_string()]);
2727 let fb_markers = fallback.iter()
2728 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2729 .count();
2730 assert_eq!(fb_markers, 1, "still exactly one marker on the fallback path");
2731 }
2732
2733 #[test]
2734 fn shortcode_validator_accepts_alphanum_dash_underscore() {
2735 assert!(is_valid_shortcode("smile"));
2736 assert!(is_valid_shortcode("smile_face"));
2737 assert!(is_valid_shortcode("smile-face"));
2738 assert!(is_valid_shortcode("Smile2"));
2739 assert!(!is_valid_shortcode(""));
2740 assert!(!is_valid_shortcode("smile face"));
2741 assert!(!is_valid_shortcode("smile:face"));
2742 assert!(!is_valid_shortcode("😀"));
2743 assert!(!is_valid_shortcode("love~2"));
2746 }
2747
2748 #[test]
2749 fn resolve_token_disambiguates_duplicate_shortcodes() {
2750 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
2752 by_code.insert(
2753 "love".to_string(),
2754 vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
2755 );
2756 by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
2757
2758 assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
2760 assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
2762 assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
2763 assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
2765 assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
2766 assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
2767 assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
2769 assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
2770 }
2771
2772 #[test]
2773 fn message_emoji_tags_accept_disambiguation_separator() {
2774 let tags = vec![
2776 vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
2777 vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
2778 ];
2779 let out = crate::types::EmojiTag::extract_from_stored(&tags);
2780 assert_eq!(out.len(), 1);
2781 assert_eq!(out[0].shortcode, "love~2");
2782 }
2783}