1use std::collections::HashMap;
16
17use nostr_sdk::prelude::*;
18use serde::{Deserialize, Serialize};
19
20use crate::state::nostr_client;
21use crate::ClientRelayExt;
22
23const KIND_EMOJI_LIST: u16 = 10030;
25
26pub const KIND_EMOJI_SET: u16 = 30030;
28
29const 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
40fn 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
55const FETCH_TIMEOUT_SECS: u64 = 20;
61
62const EQUIPPED_PACKS_BY_TIER: [usize; 4] = [3, 6, 9, usize::MAX];
67const EMOJIS_PER_PACK_BY_TIER: [usize; 4] = [30, 30, 60, 90];
69
70pub const MAX_EQUIPPED_PACKS: usize = EQUIPPED_PACKS_BY_TIER[0];
72
73pub const MAX_EMOJIS_PER_PACK: usize = EMOJIS_PER_PACK_BY_TIER[0];
77
78pub fn effective_max_equipped_packs() -> usize {
81 EQUIPPED_PACKS_BY_TIER[crate::badges::effective_tier() as usize]
82}
83
84pub fn effective_max_emojis_per_pack() -> usize {
86 EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
87}
88
89#[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 pub id: String,
109 pub pubkey: String,
111 pub identifier: String,
113 pub title: String,
115 pub image_url: String,
117 pub description: String,
119 pub emojis: Vec<PackEmoji>,
120 pub is_own: bool,
122 pub updated_at: u64,
125 pub status: u8,
130}
131
132pub const PACK_STATUS_ACTIVE: u8 = 0;
134pub const PACK_STATUS_REVOKED: u8 = 1;
138pub const PACK_STATUS_MISSING: u8 = 2;
142
143#[derive(Debug, Clone, PartialEq)]
145pub enum PackFetchOutcome {
146 Found,
148 Tombstoned,
150 CleanMiss,
152 Unreachable,
154}
155
156const PACK_MISS_PROMOTE_COUNT: i64 = 3;
160const PACK_MISS_PROMOTE_SECS: i64 = 48 * 3600;
161const PACK_MISS_RATELIMIT_SECS: i64 = 12 * 3600;
164
165impl EmojiPack {
166 pub fn addr(&self) -> String {
170 build_pack_addr(&self.pubkey, &self.identifier)
171 }
172}
173
174fn is_valid_shortcode(s: &str) -> bool {
182 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
183}
184
185struct ThemeEmojiTags;
192
193fn theme_emoji_tags() -> std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> {
194 crate::db::current_session().scoped::<ThemeEmojiTags, _>()
195}
196
197pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
200 if let Ok(mut g) = theme_emoji_tags().lock() {
201 *g = tags;
202 }
203}
204
205pub fn effective_display_cap() -> usize {
211 EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
212}
213
214fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
219 if let Some((base, suffix)) = token.rsplit_once('~') {
220 let n: usize = suffix.parse().ok()?;
221 if n == 0 { return None; }
222 return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
223 }
224 by_code.get(token).and_then(|urls| urls.first().cloned())
225}
226
227pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
236 if content.is_empty() || !content.contains(':') {
237 return Vec::new();
238 }
239
240 let cap = effective_display_cap();
242 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
243
244 if let Ok(conn) = crate::db::get_db_connection_guard_static() {
250 if let Ok(mut stmt) = conn.prepare(
251 "SELECT p.addr, i.shortcode, i.url
252 FROM emoji_pack_items i
253 INNER JOIN emoji_packs p ON p.addr = i.pack_addr
254 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
255 WHERE p.status = 0
256 ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
257 ) {
258 if let Ok(rows) = stmt.query_map([], |row| {
259 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
260 }) {
261 let mut per_pack: HashMap<String, usize> = HashMap::new();
264 for (addr, code, url) in rows.flatten() {
265 let n = per_pack.entry(addr).or_insert(0);
266 if *n >= cap { continue; }
267 *n += 1;
268 by_code.entry(code).or_default().push(url);
269 }
270 }
271 }
272 }
273
274 if let Ok(theme) = theme_emoji_tags().lock() {
278 for (code, url) in theme.iter() {
279 by_code.entry(code.clone()).or_default().push(url.clone());
280 }
281 }
282
283 if by_code.is_empty() {
284 return Vec::new();
285 }
286
287 for urls in by_code.values_mut() {
290 urls.sort();
291 urls.dedup();
292 }
293
294 let mut out: Vec<crate::types::EmojiTag> = Vec::new();
295 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
296 let bytes = content.as_bytes();
297 let mut i = 0;
298 while i < bytes.len() {
299 if bytes[i] == b':' {
300 let start = i + 1;
301 let mut j = start;
302 while j < bytes.len() {
303 let c = bytes[j];
304 let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
306 if !ok { break; }
307 j += 1;
308 }
309 if j > start && j < bytes.len() && bytes[j] == b':' {
310 if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
311 if !seen.contains(token) {
312 if let Some(url) = resolve_emoji_token(&by_code, token) {
313 out.push(crate::types::EmojiTag {
314 shortcode: token.to_string(),
315 url,
316 });
317 seen.insert(token.to_string());
318 }
319 }
320 }
321 i = j + 1;
322 continue;
323 }
324 }
325 i += 1;
326 }
327 out
328}
329
330fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
333 for key in keys {
334 for tag in tags.iter() {
335 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
336 if parts.len() >= 2 && parts[0] == *key {
337 return Some(parts[1].to_string());
338 }
339 }
340 }
341 None
342}
343
344pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
347 if event.kind.as_u16() != KIND_EMOJI_SET {
348 return None;
349 }
350
351 let identifier = first_tag(&event.tags, &["d"])?;
352 let pubkey = event.pubkey.to_hex();
353 let addr = build_pack_addr(&pubkey, &identifier);
354 let id = match naddr_from_addr(&addr) {
355 Ok(s) => s,
356 Err(e) => {
357 crate::log_warn!(
358 "[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
359 addr, e,
360 );
361 return None;
362 }
363 };
364
365 let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
366 let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
367 let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
368
369 let mut emojis = Vec::new();
370 let mut seen = std::collections::HashSet::new();
371 for tag in event.tags.iter() {
372 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
373 if parts.len() >= 3 && parts[0] == "emoji" {
374 let shortcode = parts[1];
375 if !is_valid_shortcode(shortcode) { continue; }
376 let url = parts[2].trim();
380 if url.is_empty() { continue; }
381 if !seen.insert(shortcode.to_string()) { continue; }
382 emojis.push(PackEmoji {
383 shortcode: shortcode.to_string(),
384 url: url.to_string(),
385 sha256: None,
386 });
387 }
388 }
389
390 if emojis.is_empty() {
391 return None;
392 }
393
394 let is_own = my_pubkey_hex.map_or(false, |me| me == pubkey);
395
396 Some(EmojiPack {
397 id,
398 pubkey,
399 identifier,
400 title,
401 image_url,
402 description,
403 emojis,
404 is_own,
405 updated_at: event.created_at.as_secs(),
406 status: PACK_STATUS_ACTIVE,
407 })
408}
409
410#[derive(Debug, Clone, PartialEq)]
412pub struct PackAddress {
413 pub kind: u16,
414 pub pubkey: PublicKey,
415 pub identifier: String,
416}
417
418pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
422 let mut parts = addr.splitn(3, ':');
423 let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
424 let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
425 let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
426
427 let kind: u16 = kind_str.parse()
428 .map_err(|_| format!("invalid kind: {}", kind_str))?;
429 if kind != KIND_EMOJI_SET {
430 return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
431 }
432 let pubkey = PublicKey::from_hex(pubkey_str)
433 .map_err(|e| format!("invalid pubkey: {}", e))?;
434
435 Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
436}
437
438impl PackAddress {
439 pub fn to_addr_string(&self) -> String {
444 debug_assert_eq!(self.kind, KIND_EMOJI_SET,
445 "PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
446 build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
447 }
448}
449
450pub async fn share_naddr(naddr: &str) -> Result<String, String> {
457 let coordinate = match Nip19::from_bech32(naddr) {
458 Ok(Nip19::Coordinate(c)) => c.coordinate,
459 _ => return Err("not a pack naddr".to_string()),
460 };
461 if coordinate.kind != Kind::Custom(KIND_EMOJI_SET) {
462 return Err("not an emoji pack coordinate".to_string());
463 }
464 let client = nostr_client().ok_or_else(|| "Nostr client not initialized".to_string())?;
465 let mut relays = fetch_author_write_relays(&client, coordinate.public_key).await;
466 if relays.is_empty() {
467 relays = client
470 .relays()
471 .await
472 .into_iter()
473 .filter(|(_, r)| r.capabilities().load().can_write())
474 .map(|(url, _)| url)
475 .collect();
476 }
477 relays.truncate(3);
478 if relays.is_empty() {
479 return Ok(naddr.to_string());
480 }
481 Nip19::Coordinate(Nip19Coordinate { coordinate, relays })
482 .to_bech32()
483 .map_err(|e| format!("encode naddr: {}", e))
484}
485
486pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
490 let parsed = parse_pack_address(addr)?;
491 let coord = nostr_sdk::prelude::nip01::Coordinate {
492 kind: Kind::Custom(parsed.kind),
493 public_key: parsed.pubkey,
494 identifier: parsed.identifier,
495 };
496 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
497 coordinate: coord,
498 relays: Vec::new(),
499 };
500 nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
501 .to_bech32()
502 .map_err(|e| format!("encode naddr: {}", e))
503}
504
505pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
509 let trimmed = naddr.trim().trim_start_matches("nostr:");
510 let parsed = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed)
511 .map_err(|e| format!("invalid naddr: {}", e))?;
512 let coord = match parsed {
513 nostr_sdk::prelude::nip19::Nip19::Coordinate(c) => c,
514 _ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
515 };
516 let kind = coord.kind.as_u16();
517 if kind != KIND_EMOJI_SET {
518 return Err(format!(
519 "expected kind {} (emoji set), got {}",
520 KIND_EMOJI_SET, kind,
521 ));
522 }
523 Ok(PackAddress {
524 kind,
525 pubkey: coord.public_key,
526 identifier: coord.identifier.clone(),
527 })
528}
529
530const THEME_SLOT_TOKEN: &str = "theme_slot";
536
537fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
542 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
543 Ok(v) => v,
544 Err(e) => {
545 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
546 return Vec::new();
547 }
548 };
549 inner.into_iter()
550 .filter_map(|tup| {
551 if tup.len() >= 2 && tup[0] == "a" {
552 parse_pack_address(&tup[1]).ok()
553 } else {
554 None
555 }
556 })
557 .collect()
558}
559
560fn parse_inner_tag_list_with_anchor(plaintext: &str) -> (Vec<String>, Option<String>) {
567 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
568 Ok(v) => v,
569 Err(e) => {
570 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
571 return (Vec::new(), None);
572 }
573 };
574 let mut addrs: Vec<String> = Vec::new();
575 let mut anchor: Option<String> = None;
576 for tup in inner {
577 if tup.len() >= 2 && tup[0] == "a" {
578 if let Ok(pa) = parse_pack_address(&tup[1]) {
581 addrs.push(pa.to_addr_string());
582 }
583 } else if anchor.is_none() && tup.first().map(String::as_str) == Some(THEME_SLOT_TOKEN) {
584 anchor = Some(addrs.last().cloned().unwrap_or_default());
587 }
588 }
589 (addrs, anchor)
590}
591
592pub async fn decrypt_subscribed_addresses(
600 client: &Client,
601 my_pk: &PublicKey,
602 event: &Event,
603) -> Vec<PackAddress> {
604 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
605 Some(plaintext) => parse_inner_tag_list(&plaintext),
606 None => Vec::new(),
607 }
608}
609
610async fn decrypt_emoji_list_plaintext(
613 _client: &Client,
614 my_pk: &PublicKey,
615 event: &Event,
616) -> Option<String> {
617 if event.content.is_empty() {
618 return None;
619 }
620 let signer = match crate::signer::active_signer() {
621 Ok(s) => s,
622 Err(e) => {
623 crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
624 return None;
625 }
626 };
627 match signer.nip44_decrypt_async(my_pk, &event.content).await {
628 Ok(p) => Some(p),
629 Err(e) => {
630 crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
631 None
632 }
633 }
634}
635
636async fn decrypt_subscribed_addresses_with_anchor(
639 client: &Client,
640 my_pk: &PublicKey,
641 event: &Event,
642) -> (Vec<PackAddress>, Option<String>) {
643 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
644 Some(plaintext) => {
645 let (raw, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
646 let addrs = raw.iter().filter_map(|s| parse_pack_address(s).ok()).collect();
647 (addrs, anchor)
648 }
649 None => (Vec::new(), None),
650 }
651}
652
653pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
658 let mut conn = crate::db::get_write_connection_guard_static()?;
659 let tx = conn.transaction()
660 .map_err(|e| format!("Failed to start tx: {}", e))?;
661
662 let addr = pack.addr();
663 tx.execute(
664 "INSERT OR REPLACE INTO emoji_packs
665 (addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
666 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
667 rusqlite::params![
668 addr, pack.pubkey, pack.identifier,
669 pack.title, pack.image_url, pack.description,
670 pack.is_own as i32, pack.updated_at as i64,
671 ],
672 ).map_err(|e| format!("Failed to upsert pack: {}", e))?;
673
674 tx.execute(
678 "DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
679 rusqlite::params![addr],
680 ).map_err(|e| format!("Failed to clear pack items: {}", e))?;
681
682 for (pos, emoji) in pack.emojis.iter().enumerate() {
683 tx.execute(
684 "INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
685 VALUES (?1, ?2, ?3, ?4, ?5)",
686 rusqlite::params![
687 addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
688 ],
689 ).map_err(|e| format!("Failed to insert pack item: {}", e))?;
690 }
691
692 tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
693 Ok(())
694}
695
696pub fn apply_pack_health(addr: &str, outcome: &PackFetchOutcome, now: i64) -> Result<bool, String> {
713 use rusqlite::OptionalExtension;
714 if matches!(outcome, PackFetchOutcome::Unreachable) {
715 return Ok(false);
716 }
717 let mut conn = crate::db::get_write_connection_guard_static()?;
721 let tx = conn
722 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
723 .map_err(|e| format!("begin pack health tx: {}", e))?;
724
725 let row = tx
726 .query_row(
727 "SELECT status, miss_count, first_missed_at, last_miss_counted_at
728 FROM emoji_packs WHERE addr = ?1",
729 rusqlite::params![addr],
730 |r| {
731 Ok((
732 r.get::<_, i64>(0)?,
733 r.get::<_, i64>(1)?,
734 r.get::<_, i64>(2)?,
735 r.get::<_, i64>(3)?,
736 ))
737 },
738 )
739 .optional()
740 .map_err(|e| format!("read pack health: {}", e))?;
741 let Some((status, miss_count, first_missed_at, last_miss_counted_at)) = row else {
742 return Ok(false);
743 };
744
745 let (new_status, new_miss, new_first, new_last) = match outcome {
746 PackFetchOutcome::Found => (PACK_STATUS_ACTIVE as i64, 0, 0, 0),
747 PackFetchOutcome::Tombstoned => (PACK_STATUS_REVOKED as i64, 0, 0, 0),
748 PackFetchOutcome::CleanMiss => {
749 if status == PACK_STATUS_REVOKED as i64 {
750 (status, miss_count, first_missed_at, last_miss_counted_at)
751 } else if now - last_miss_counted_at < PACK_MISS_RATELIMIT_SECS {
752 (status, miss_count, first_missed_at, last_miss_counted_at)
753 } else {
754 let first = if miss_count == 0 { now } else { first_missed_at };
755 let count = miss_count + 1;
756 let promoted = count >= PACK_MISS_PROMOTE_COUNT
757 && now - first >= PACK_MISS_PROMOTE_SECS;
758 (
759 if promoted { PACK_STATUS_MISSING as i64 } else { status },
760 count,
761 first,
762 now,
763 )
764 }
765 }
766 PackFetchOutcome::Unreachable => unreachable!(),
767 };
768
769 let changed = new_status != status;
770 tx.execute(
771 "UPDATE emoji_packs SET status = ?2, miss_count = ?3, first_missed_at = ?4,
772 last_miss_counted_at = ?5,
773 status_changed_at = CASE WHEN status != ?2 THEN ?6 ELSE status_changed_at END
774 WHERE addr = ?1",
775 rusqlite::params![addr, new_status, new_miss, new_first, new_last, now],
776 )
777 .map_err(|e| format!("write pack health: {}", e))?;
778
779 if changed && new_status != PACK_STATUS_ACTIVE as i64 {
782 tx.execute(
783 "DELETE FROM emoji_usage WHERE kind = 1 AND url IN
784 (SELECT url FROM emoji_pack_items WHERE pack_addr = ?1)",
785 rusqlite::params![addr],
786 )
787 .map_err(|e| format!("purge dead pack usage: {}", e))?;
788 }
789
790 tx.commit().map_err(|e| format!("commit pack health: {}", e))?;
791 Ok(changed)
792}
793
794pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
795 let mut conn = crate::db::get_write_connection_guard_static()?;
796 let tx = conn.transaction()
797 .map_err(|e| format!("Failed to start tx: {}", e))?;
798
799 tx.execute("DELETE FROM emoji_pack_subscriptions", [])
800 .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
801
802 let now = std::time::SystemTime::now()
803 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
804 for (pos, addr) in addrs.iter().enumerate() {
808 tx.execute(
809 "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at, position)
810 VALUES (?1, ?2, ?3)",
811 rusqlite::params![addr, now, pos as i64],
812 ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
813 }
814
815 tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
816 Ok(())
817}
818
819pub fn load_subscriptions() -> Result<Vec<String>, String> {
820 let conn = crate::db::get_db_connection_guard_static()?;
821 let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY position ASC, subscribed_at ASC")
822 .map_err(|e| format!("prepare: {}", e))?;
823 let rows = stmt.query_map([], |row| row.get::<_, String>(0))
824 .map_err(|e| format!("query: {}", e))?;
825 let mut out = Vec::new();
826 for r in rows {
827 out.push(r.map_err(|e| format!("row: {}", e))?);
828 }
829 Ok(out)
830}
831
832pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
837 let conn = crate::db::get_db_connection_guard_static()?;
838
839 let mut pack = match conn.query_row(
840 "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at, status
841 FROM emoji_packs WHERE addr = ?1",
842 rusqlite::params![addr],
843 |row| {
844 Ok(EmojiPack {
845 id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
846 pubkey: row.get(0)?,
847 identifier: row.get(1)?,
848 title: row.get(2)?,
849 image_url: row.get(3)?,
850 description: row.get(4)?,
851 is_own: row.get::<_, i32>(5)? != 0,
852 updated_at: row.get::<_, i64>(6)? as u64,
853 emojis: Vec::new(),
854 status: row.get::<_, i64>(7)? as u8,
855 })
856 },
857 ) {
858 Ok(p) => p,
859 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
860 Err(e) => return Err(format!("query cached pack: {}", e)),
861 };
862
863 let mut stmt = conn.prepare(
864 "SELECT shortcode, url, sha256 FROM emoji_pack_items
865 WHERE pack_addr = ?1 ORDER BY position ASC"
866 ).map_err(|e| format!("prepare items: {}", e))?;
867 let rows = stmt.query_map(rusqlite::params![addr], |row| {
868 Ok(PackEmoji {
869 shortcode: row.get(0)?,
870 url: row.get(1)?,
871 sha256: row.get(2)?,
872 })
873 }).map_err(|e| format!("query items: {}", e))?;
874 for r in rows {
875 pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
876 }
877
878 Ok(Some(pack))
879}
880
881pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
886 let conn = crate::db::get_db_connection_guard_static()?;
887
888 let mut packs: HashMap<String, EmojiPack> = HashMap::new();
889 let mut order: Vec<String> = Vec::new();
890
891 {
892 let mut stmt = conn.prepare(
899 "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at, p.status
900 FROM emoji_packs p
901 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
902 ORDER BY s.position ASC"
903 ).map_err(|e| format!("prepare packs: {}", e))?;
904
905 let rows = stmt.query_map([], |row| {
906 let raw_addr: String = row.get(0)?;
910 let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
911 Ok((raw_addr, EmojiPack {
912 id,
913 pubkey: row.get(1)?,
914 identifier: row.get(2)?,
915 title: row.get(3)?,
916 image_url: row.get(4)?,
917 description: row.get(5)?,
918 is_own: row.get::<_, i32>(6)? != 0,
919 updated_at: row.get::<_, i64>(7)? as u64,
920 emojis: Vec::new(),
921 status: row.get::<_, i64>(8)? as u8,
922 }))
923 }).map_err(|e| format!("query packs: {}", e))?;
924
925 for r in rows {
926 let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
927 order.push(raw_addr.clone());
928 packs.insert(raw_addr, pack);
929 }
930 }
931
932 {
933 let mut stmt = conn.prepare(
934 "SELECT pack_addr, shortcode, url, sha256
935 FROM emoji_pack_items
936 ORDER BY pack_addr, position ASC"
937 ).map_err(|e| format!("prepare items: {}", e))?;
938
939 let rows = stmt.query_map([], |row| {
940 Ok((
941 row.get::<_, String>(0)?,
942 PackEmoji {
943 shortcode: row.get(1)?,
944 url: row.get(2)?,
945 sha256: row.get(3)?,
946 },
947 ))
948 }).map_err(|e| format!("query items: {}", e))?;
949
950 for r in rows {
951 let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
952 if let Some(p) = packs.get_mut(&addr) {
953 p.emojis.push(emoji);
954 }
955 }
956 }
957
958 Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
959}
960
961const NIP65_CACHE_TTL_SECS: u64 = 3600;
970const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
973const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
974
975#[derive(Clone)]
976struct CachedRelayList {
977 relays: Vec<RelayUrl>,
978 fetched_at: std::time::Instant,
979 empty: bool,
981 verified: bool,
985}
986
987struct Nip65Cache;
990
991fn nip65_cache() -> std::sync::Arc<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> {
992 crate::db::current_session().scoped::<Nip65Cache, _>()
993}
994
995fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
998 let owner = nip65_cache();
999 let cache = owner.read().ok()?;
1000 let entry = cache.get(pubkey)?;
1001 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1002 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1003 Some(entry.relays.clone())
1004 } else {
1005 None
1006 }
1007}
1008
1009fn cached_write_relays_verified(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1013 let owner = nip65_cache();
1014 let cache = owner.read().ok()?;
1015 let entry = cache.get(pubkey)?;
1016 if !entry.verified {
1017 return None;
1018 }
1019 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1020 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1021 Some(entry.relays.clone())
1022 } else {
1023 None
1024 }
1025}
1026
1027fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>, verified: bool) {
1029 if let Ok(mut cache) = nip65_cache().write() {
1030 let empty = relays.is_empty();
1031 cache.insert(pubkey, CachedRelayList {
1032 relays,
1033 fetched_at: std::time::Instant::now(),
1034 empty,
1035 verified,
1036 });
1037 }
1038}
1039
1040fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
1044 let mut relays: Vec<RelayUrl> = Vec::new();
1045 for (url, marker) in nostr_sdk::prelude::nip65::extract_relay_list(ev) {
1046 match marker {
1047 None | Some(nostr_sdk::prelude::nip65::RelayMetadata::Write) => {
1048 if !relays.contains(&url) {
1049 relays.push(url.clone());
1050 }
1051 }
1052 Some(nostr_sdk::prelude::nip65::RelayMetadata::Read) => {}
1053 }
1054 }
1055 relays
1056}
1057
1058async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
1063 if let Some(relays) = cached_write_relays(&pubkey) {
1064 return relays;
1065 }
1066
1067 let filter = Filter::new()
1068 .author(pubkey)
1069 .kind(Kind::RelayList)
1070 .limit(1);
1071 let events = match client
1072 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1073 .await
1074 {
1075 Ok(evs) => evs,
1076 Err(_) => {
1077 cache_write_relays(pubkey, Vec::new(), false);
1078 return Vec::new();
1079 }
1080 };
1081
1082 let relays = events.into_iter()
1083 .max_by_key(|e| e.created_at)
1084 .map(|ev| extract_write_relays(&ev))
1085 .unwrap_or_default();
1086 cache_write_relays(pubkey, relays.clone(), true);
1087 relays
1088}
1089
1090async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
1096 let uncached: Vec<PublicKey> = authors.iter()
1097 .filter(|pk| cached_write_relays(pk).is_none())
1098 .copied()
1099 .collect();
1100 if uncached.is_empty() {
1101 return;
1102 }
1103
1104 let filter = Filter::new()
1105 .authors(uncached.iter().copied())
1106 .kind(Kind::RelayList);
1107 let events = match client
1108 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1109 .await
1110 {
1111 Ok(evs) => evs,
1112 Err(_) => return,
1115 };
1116
1117 let mut newest: HashMap<PublicKey, Event> = HashMap::new();
1119 for ev in events {
1120 match newest.get(&ev.pubkey) {
1121 Some(existing) if existing.created_at >= ev.created_at => {}
1122 _ => { newest.insert(ev.pubkey, ev); }
1123 }
1124 }
1125 for pk in uncached {
1126 let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
1127 cache_write_relays(pk, relays, true);
1128 }
1129}
1130
1131async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
1140 let filter = Filter::new()
1141 .author(addr.pubkey)
1142 .kind(Kind::Custom(KIND_EMOJI_SET))
1143 .identifier(&addr.identifier)
1144 .limit(1);
1145 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1146 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1147
1148 match client.fetch_events(filter.clone()).timeout(timeout).await {
1151 Ok(events) => {
1152 if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
1153 if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
1154 return Some(pack);
1155 }
1156 }
1157 }
1158 Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
1159 }
1160
1161 let outbox = fetch_author_write_relays(client, addr.pubkey).await;
1167 if outbox.is_empty() {
1168 return None;
1169 }
1170 fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
1171}
1172
1173async fn fetch_pack_via_isolated_client(
1177 relays: &[RelayUrl],
1178 filter: Filter,
1179 timeout: std::time::Duration,
1180 my_pubkey_hex: Option<&str>,
1181) -> Option<EmojiPack> {
1182 let scratch = crate::nostr_client_builder()
1183 .build();
1184 for r in relays {
1185 let _ = scratch.add_managed_relay(r.as_str()).await;
1186 }
1187 scratch.connect().await;
1188
1189 let result = scratch.fetch_events(filter).timeout(timeout).await;
1190 scratch.shutdown().await;
1192
1193 let events = match result {
1194 Ok(events) => events,
1195 Err(e) => {
1196 crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
1197 return None;
1198 }
1199 };
1200 let event = events.into_iter().max_by_key(|e| e.created_at)?;
1201 parse_pack_from_event(&event, my_pubkey_hex)
1202}
1203
1204fn event_coord(ev: &Event) -> Option<String> {
1223 let d = first_tag(&ev.tags, &["d"])?;
1224 Some(format!("{}:{}", ev.pubkey.to_hex(), d))
1225}
1226fn addr_coord(addr: &PackAddress) -> String {
1227 format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
1228}
1229
1230fn newest_events_by_coord(
1237 events: impl IntoIterator<Item = Event>,
1238 wanted: &std::collections::HashSet<String>,
1239) -> HashMap<String, Event> {
1240 let mut newest: HashMap<String, Event> = HashMap::new();
1241 for ev in events {
1242 if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
1243 let Some(coord) = event_coord(&ev) else { continue; };
1244 if !wanted.contains(&coord) { continue; }
1245 match newest.get(&coord) {
1246 Some(existing) if existing.created_at >= ev.created_at => {}
1247 _ => { newest.insert(coord, ev); }
1248 }
1249 }
1250 newest
1251}
1252
1253fn merge_newest(acc: &mut HashMap<String, Event>, phase: HashMap<String, Event>) {
1256 for (coord, ev) in phase {
1257 match acc.get(&coord) {
1258 Some(existing) if existing.created_at >= ev.created_at => {}
1259 _ => { acc.insert(coord, ev); }
1260 }
1261 }
1262}
1263
1264fn deletion_filter(addrs: &[&PackAddress]) -> Filter {
1267 Filter::new()
1268 .authors(addrs.iter().map(|a| a.pubkey))
1269 .kind(Kind::EventDeletion)
1270 .custom_tags(
1271 SingleLetterTag::LOWERCASE_A,
1272 addrs.iter().map(|a| a.to_addr_string()),
1273 )
1274}
1275
1276fn deletion_matches(ev: &Event, author: &PublicKey, raw_addr: &str) -> bool {
1279 ev.kind == Kind::EventDeletion
1280 && ev.pubkey == *author
1281 && ev.tags.iter().any(|t| {
1282 let s = t.as_slice();
1283 s.len() >= 2 && s[0] == "a" && s[1] == raw_addr
1284 })
1285}
1286
1287async fn connected_read_relays(client: &Client, read_only: bool) -> std::collections::HashSet<String> {
1292 client
1293 .relays()
1294 .await
1295 .iter()
1296 .filter(|(_, r)| r.status() == RelayStatus::Connected && (!read_only || r.capabilities().load().can_read()))
1297 .map(|(url, _)| url.to_string())
1298 .collect()
1299}
1300
1301fn absence_is_clean(
1313 home_ok: bool,
1314 live_relays: usize,
1315 author_writes: Option<&Vec<RelayUrl>>,
1316 outbox_ok: bool,
1317 outbox_live: usize,
1318) -> bool {
1319 if !home_ok || live_relays < 2 {
1320 return false;
1321 }
1322 match author_writes {
1323 None => false,
1324 Some(w) if w.is_empty() => true,
1325 Some(_) => outbox_ok && outbox_live >= 1,
1326 }
1327}
1328
1329fn classify_pack(
1332 newest_ev: Option<&Event>,
1333 newest_deletion: Option<&Event>,
1334 me: Option<&str>,
1335 absence_clean: bool,
1336) -> (PackFetchOutcome, Option<EmojiPack>) {
1337 match newest_ev {
1338 Some(ev) => match parse_pack_from_event(ev, me) {
1339 Some(pack) => {
1340 match newest_deletion {
1343 Some(del) if del.created_at > ev.created_at => {
1344 (PackFetchOutcome::Tombstoned, None)
1345 }
1346 _ => (PackFetchOutcome::Found, Some(pack)),
1347 }
1348 }
1349 None => {
1350 let has_emoji_tags = ev.tags.iter().any(|t| {
1355 t.as_slice().first().map(|k| k == "emoji").unwrap_or(false)
1356 });
1357 if has_emoji_tags {
1358 (PackFetchOutcome::Unreachable, None)
1359 } else {
1360 (PackFetchOutcome::Tombstoned, None)
1361 }
1362 }
1363 },
1364 None if newest_deletion.is_some() => (PackFetchOutcome::Tombstoned, None),
1365 None if absence_clean => (PackFetchOutcome::CleanMiss, None),
1366 None => (PackFetchOutcome::Unreachable, None),
1367 }
1368}
1369
1370struct PackSweep {
1373 packs: Vec<EmojiPack>,
1374 outcomes: HashMap<String, PackFetchOutcome>,
1376}
1377
1378async fn sweep_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> PackSweep {
1384 if addrs.is_empty() {
1385 return PackSweep { packs: Vec::new(), outcomes: HashMap::new() };
1386 }
1387 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1388 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1389 let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1390 let all_refs: Vec<&PackAddress> = addrs.iter().collect();
1391
1392 let home_before = connected_read_relays(client, true).await;
1399 let home_filter = Filter::new()
1400 .authors(addrs.iter().map(|a| a.pubkey))
1401 .kind(Kind::Custom(KIND_EMOJI_SET))
1402 .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1403 let mut newest: HashMap<String, Event> = HashMap::new();
1404 let mut deletions: Vec<Event> = Vec::new();
1405 let mut home_ok = false;
1406 match client.fetch_events(home_filter).timeout(timeout).await {
1407 Ok(events) => {
1408 home_ok = true;
1409 merge_newest(&mut newest, newest_events_by_coord(events, &wanted));
1410 }
1411 Err(e) => {
1412 crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1413 }
1414 }
1415 if home_ok {
1416 match client.fetch_events(deletion_filter(&all_refs)).timeout(timeout).await {
1417 Ok(events) => deletions.extend(events),
1418 Err(e) => crate::log_warn!("[EmojiPacks] home deletion fetch failed: {}", e),
1419 }
1420 }
1421 let home_after = connected_read_relays(client, true).await;
1422 let mut live_relays: std::collections::HashSet<String> =
1423 home_before.intersection(&home_after).cloned().collect();
1424
1425 let misses: Vec<&PackAddress> = addrs.iter()
1429 .filter(|a| !newest.contains_key(&addr_coord(a)))
1430 .collect();
1431 let mut outbox_ok = false;
1432 let mut outbox_live = 0usize;
1433 if !misses.is_empty() {
1434 let miss_authors: Vec<PublicKey> = {
1435 let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1436 v.sort(); v.dedup();
1437 v
1438 };
1439 prefetch_author_write_relays(client, &miss_authors).await;
1442 let mut outbox: Vec<RelayUrl> = Vec::new();
1443 for pk in &miss_authors {
1444 for r in cached_write_relays(pk).unwrap_or_default() {
1445 if !outbox.contains(&r) { outbox.push(r); }
1446 }
1447 }
1448 if !outbox.is_empty() {
1449 let miss_filter = Filter::new()
1450 .authors(misses.iter().map(|a| a.pubkey))
1451 .kind(Kind::Custom(KIND_EMOJI_SET))
1452 .identifiers(misses.iter().map(|a| a.identifier.clone()));
1453 let wanted_misses: std::collections::HashSet<String> =
1454 misses.iter().map(|a| addr_coord(a)).collect();
1455 if let Some((events, dels, live)) = sweep_via_isolated_client(
1456 &outbox, miss_filter, deletion_filter(&misses), timeout,
1457 ).await {
1458 outbox_ok = true;
1459 outbox_live = live.len();
1460 live_relays.extend(live);
1461 merge_newest(&mut newest, newest_events_by_coord(events, &wanted_misses));
1462 deletions.extend(dels);
1463 }
1464 }
1465 }
1466
1467 let mut packs: Vec<EmojiPack> = Vec::new();
1469 let mut outcomes: HashMap<String, PackFetchOutcome> = HashMap::new();
1470 for addr in addrs {
1471 let coord = addr_coord(addr);
1472 let raw_addr = addr.to_addr_string();
1473 let newest_deletion = deletions.iter()
1474 .filter(|d| deletion_matches(d, &addr.pubkey, &raw_addr))
1475 .max_by_key(|d| d.created_at);
1476 let author_writes = cached_write_relays_verified(&addr.pubkey);
1477 let clean = absence_is_clean(
1478 home_ok,
1479 live_relays.len(),
1480 author_writes.as_ref(),
1481 outbox_ok,
1482 outbox_live,
1483 );
1484 let (outcome, pack) = classify_pack(
1485 newest.get(&coord),
1486 newest_deletion,
1487 me.as_deref(),
1488 clean,
1489 );
1490 if let Some(p) = pack {
1491 packs.push(p);
1492 }
1493 outcomes.insert(raw_addr, outcome);
1494 }
1495
1496 PackSweep { packs, outcomes }
1497}
1498
1499async fn sweep_via_isolated_client(
1506 relays: &[RelayUrl],
1507 pack_filter: Filter,
1508 del_filter: Filter,
1509 timeout: std::time::Duration,
1510) -> Option<(Vec<Event>, Vec<Event>, std::collections::HashSet<String>)> {
1511 let scratch = crate::nostr_client_builder()
1512 .build();
1513 for r in relays {
1514 let _ = scratch.add_managed_relay(r.as_str()).await;
1515 }
1516 scratch.connect().await;
1517 let connect_deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
1522 let before = loop {
1523 let connected = connected_read_relays(&scratch, false).await;
1524 if !connected.is_empty() || std::time::Instant::now() >= connect_deadline {
1525 break connected;
1526 }
1527 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1528 };
1529 let result = scratch.fetch_events(pack_filter).timeout(timeout).await;
1530 let dels = match &result {
1531 Ok(_) => scratch.fetch_events(del_filter).timeout(timeout).await.ok(),
1532 Err(_) => None,
1533 };
1534 let after = connected_read_relays(&scratch, false).await;
1535 scratch.shutdown().await;
1536
1537 match result {
1538 Ok(events) => Some((
1539 events.into_iter().collect(),
1540 dels.map(|d| d.into_iter().collect()).unwrap_or_default(),
1541 before.intersection(&after).cloned().collect(),
1542 )),
1543 Err(e) => {
1544 crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1545 None
1546 }
1547 }
1548}
1549
1550pub async fn fetch_subscribed_packs(
1559 client: &Client,
1560 my_pubkey: PublicKey,
1561) -> Result<Vec<EmojiPack>, String> {
1562 crate::db::scoped(async move {
1563 let list_filter = Filter::new()
1564 .author(my_pubkey)
1565 .kind(Kind::Custom(KIND_EMOJI_LIST))
1566 .limit(1);
1567
1568 let list_events = client
1569 .fetch_events(list_filter).timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1570 .await
1571 .map_err(|e| format!("fetch kind 10030: {}", e))?;
1572
1573
1574 let local_addrs = || -> Vec<PackAddress> {
1582 load_subscriptions()
1583 .unwrap_or_default()
1584 .into_iter()
1585 .filter_map(|s| parse_pack_address(&s).ok())
1586 .collect()
1587 };
1588 let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1589 .ok()
1590 .flatten()
1591 .and_then(|s| s.parse().ok())
1592 .unwrap_or(0);
1593
1594 let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1595 let mut fetched_anchor: Option<String> = None;
1598 let addrs: Vec<PackAddress> = match list_event {
1599 Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1600 crate::log_debug!(
1601 "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1602 ev.created_at.as_secs(), our_last_publish,
1603 );
1604 local_addrs()
1605 }
1606 Some(ev) => {
1607 let (a, anchor) = decrypt_subscribed_addresses_with_anchor(client, &my_pubkey, &ev).await;
1608 fetched_anchor = anchor;
1609 a
1610 }
1611 None => {
1612 crate::log_debug!(
1613 "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1614 );
1615 local_addrs()
1616 }
1617 };
1618
1619 let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1620
1621 let PackSweep { packs: fresh, outcomes } = sweep_packs_from_relays(client, &addrs).await;
1628 let tombstoned = outcomes.values().filter(|o| matches!(o, PackFetchOutcome::Tombstoned)).count();
1629 let unresolved = addrs.len().saturating_sub(fresh.len()).saturating_sub(tombstoned);
1630 if unresolved > 0 {
1631 crate::log_warn!(
1632 "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1633 unresolved,
1634 );
1635 }
1636 if tombstoned > 0 {
1637 crate::log_info!("[EmojiPacks] {} subscribed pack(s) deleted by their creator", tombstoned);
1638 }
1639
1640
1641 let me_hex = my_pubkey.to_hex();
1647 let now = std::time::SystemTime::now()
1648 .duration_since(std::time::UNIX_EPOCH)
1649 .map(|d| d.as_secs() as i64)
1650 .unwrap_or(0);
1651 let mut health_changed = false;
1652 for addr in &addrs {
1653 if addr.pubkey.to_hex() == me_hex { continue; }
1654 let raw = addr.to_addr_string();
1655 let Some(outcome) = outcomes.get(&raw) else { continue; };
1656 match apply_pack_health(&raw, outcome, now) {
1657 Ok(true) => {
1658 health_changed = true;
1659 crate::log_info!(
1660 "[EmojiPacks] pack `{}` health changed ({:?})",
1661 addr.identifier, outcome,
1662 );
1663 }
1664 Ok(false) => {}
1665 Err(e) => crate::log_warn!("[EmojiPacks] health update for `{}` failed: {}", addr.identifier, e),
1666 }
1667 }
1668 for pack in &fresh {
1669 if let Err(e) = save_pack(pack) {
1670 crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1671 }
1672 }
1673 let list_changed = load_subscriptions().unwrap_or_default() != addr_strings
1678 || fetched_anchor.as_deref().map_or(false, |a| a != load_theme_slot_anchor());
1679
1680 if let Err(e) = save_subscriptions(&addr_strings) {
1684 crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1685 }
1686 if let Some(anchor) = fetched_anchor {
1689 if let Err(e) = save_theme_slot_anchor(&anchor) {
1690 crate::log_warn!("[EmojiPacks] save theme slot anchor failed: {}", e);
1691 }
1692 }
1693 if health_changed || list_changed {
1694 crate::traits::emit_event("emoji_packs_updated", &());
1695 }
1696
1697 crate::log_info!(
1698 "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1699 fresh.len(),
1700 addrs.len(),
1701 if unresolved > 0 {
1702 format!(" ({} via cache)", unresolved)
1703 } else {
1704 String::new()
1705 },
1706 );
1707
1708 load_all_packs()
1711 })
1712 .await
1713}
1714
1715pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1720 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1721 let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1722 fetch_subscribed_packs(&client, me).await
1723}
1724
1725pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1729 let addr = parse_naddr(naddr)?;
1730 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1731 fetch_pack_from_relays(&client, &addr).await
1732 .ok_or_else(|| {
1733 crate::log_debug!("[EmojiPacks] preview miss: {}:{}", addr.pubkey.to_hex(), addr.identifier);
1736 "Pack not found on any relay".to_string()
1737 })
1738}
1739
1740pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1748 crate::db::scoped(async move {
1749 let addr = parse_naddr(naddr)?;
1750 let coord = addr.to_addr_string();
1751
1752 if let Some(cached) = load_cached_pack(&coord)? {
1755 let naddr_owned = naddr.to_string();
1756 crate::db::spawn_bound(async move {
1757 let Some(client) = nostr_client() else { return };
1758 if let Ok(parsed) = parse_naddr(&naddr_owned) {
1759 if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1760 if fresh.updated_at > cached.updated_at {
1761 if let Err(e) = save_pack(&fresh) {
1762 crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1763 } else {
1764 crate::traits::emit_event("emoji_packs_updated", &());
1765 }
1766 }
1767 }
1768 }
1769 });
1770 return Ok(Some(cached));
1771 }
1772
1773 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1775 match fetch_pack_from_relays(&client, &addr).await {
1776 Some(pack) => {
1777 if let Err(e) = save_pack(&pack) {
1781 crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1782 }
1783 Ok(Some(pack))
1784 }
1785 None => Ok(None),
1786 }
1787 })
1788 .await
1789}
1790
1791const THEME_SLOT_ANCHOR_KEY: &str = "emoji_theme_slot_anchor";
1797
1798fn load_theme_slot_anchor() -> String {
1800 crate::db::settings::get_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string())
1801 .ok()
1802 .flatten()
1803 .unwrap_or_default()
1804}
1805
1806fn save_theme_slot_anchor(raw_addr: &str) -> Result<(), String> {
1808 crate::db::settings::set_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string(), raw_addr.to_string())
1809}
1810
1811fn build_inner_tags_with_marker(addrs: &[String], anchor: &str) -> Vec<Vec<String>> {
1816 let mut inner: Vec<Vec<String>> = Vec::with_capacity(addrs.len() + 1);
1817 let mut placed = false;
1818 if anchor.is_empty() {
1819 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1820 placed = true;
1821 }
1822 for addr in addrs {
1823 inner.push(vec!["a".to_string(), addr.clone()]);
1824 if !placed && addr == anchor {
1825 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1826 placed = true;
1827 }
1828 }
1829 if !placed {
1832 inner.insert(0, vec![THEME_SLOT_TOKEN.to_string()]);
1833 }
1834 inner
1835}
1836
1837pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1848 let addrs = load_subscriptions()?;
1849 let anchor = load_theme_slot_anchor();
1850 let my_pk = crate::state::my_public_key()
1851 .ok_or_else(|| "Not logged in".to_string())?;
1852
1853 let inner_tags = build_inner_tags_with_marker(&addrs, &anchor);
1854 let plaintext = serde_json::to_string(&inner_tags)
1855 .map_err(|e| format!("Serialise emoji list: {}", e))?;
1856
1857 let signer = crate::signer::active_signer()
1858 .map_err(|e| format!("Signer unavailable: {}", e))?;
1859 let content = signer.nip44_encrypt_async(&my_pk, &plaintext).await
1860 .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1861
1862 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1863 crate::sign_and_send(&client, builder).await
1864 .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1865
1866 crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1867 Ok(())
1868}
1869
1870const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1874
1875static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1876
1877pub fn republish_emoji_list_debounced() {
1881 use std::sync::atomic::Ordering;
1882 let _ = crate::db::settings::set_sql_setting(
1888 EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1889 Timestamp::now().as_secs().to_string(),
1890 );
1891 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1892 crate::db::spawn_bound(async move {
1893 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1894 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1895 let client = match nostr_client() {
1896 Some(c) => c,
1897 None => return,
1898 };
1899 if let Err(e) = publish_emoji_list(&client).await {
1900 crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1901 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1902 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1903 if let Err(e2) = publish_emoji_list(&client).await {
1904 crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1905 }
1906 }
1907 });
1908}
1909
1910pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1914 crate::db::scoped(async move {
1915 let pack = fetch_pack_by_naddr(naddr).await?;
1916
1917 let pack_addr = pack.addr();
1920 {
1921 let existing_subs = load_subscriptions()?;
1922 let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
1923 let cap = effective_max_equipped_packs();
1924 if is_new && existing_subs.len() >= cap {
1925 return Err(format!(
1926 "You can equip at most {} packs. Remove one to add another.",
1927 cap,
1928 ));
1929 }
1930 }
1931
1932 save_pack(&pack)?;
1933
1934 let mut subs = load_subscriptions()?;
1935 if !subs.iter().any(|a| a == &pack_addr) {
1936 subs.push(pack_addr.clone());
1937 }
1938 save_subscriptions(&subs)?;
1939
1940 republish_emoji_list_debounced();
1941 crate::traits::emit_event("emoji_packs_updated", &());
1942
1943 Ok(pack)
1944 })
1945 .await
1946}
1947
1948fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1957 if pack.identifier.is_empty() {
1958 return Err("pack identifier required".to_string());
1959 }
1960 let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1961 .tag(Tag::custom("d", [pack.identifier.clone()]))
1962 .tag(Tag::custom("client", ["vector"]));
1965
1966 if !pack.title.is_empty() {
1968 builder = builder
1969 .tag(Tag::custom("title", [pack.title.clone()]))
1970 .tag(Tag::custom("name", [pack.title.clone()]));
1971 }
1972 if !pack.image_url.is_empty() {
1973 builder = builder
1974 .tag(Tag::custom("image", [pack.image_url.clone()]))
1975 .tag(Tag::custom("picture", [pack.image_url.clone()]));
1976 }
1977 if !pack.description.is_empty() {
1978 builder = builder
1979 .tag(Tag::custom("description", [pack.description.clone()]))
1980 .tag(Tag::custom("about", [pack.description.clone()]));
1981 }
1982
1983 for e in &pack.emojis {
1984 if e.shortcode.is_empty() || e.url.is_empty() { continue; }
1985 builder = builder.tag(Tag::custom(
1986 "emoji",
1987 [e.shortcode.clone(), e.url.clone()],
1988 ));
1989 }
1990 Ok(builder)
1991}
1992
1993pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
1998 crate::db::scoped(async move {
1999 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2000 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2001
2002 let emoji_cap = effective_max_emojis_per_pack();
2005 if pack.emojis.len() > emoji_cap {
2006 return Err(format!(
2007 "A pack can hold at most {} emojis.",
2008 emoji_cap,
2009 ));
2010 }
2011
2012 let mut to_save = pack.clone();
2015 to_save.pubkey = my_pk.to_hex();
2016 to_save.is_own = true;
2017 let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
2018 to_save.id = naddr_from_addr(&raw_addr)?;
2019 to_save.updated_at = std::time::SystemTime::now()
2020 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
2021
2022 {
2025 let existing_subs = load_subscriptions()?;
2026 let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
2027 let cap = effective_max_equipped_packs();
2028 if is_new && existing_subs.len() >= cap {
2029 return Err(format!(
2030 "You can equip at most {} packs. Remove one to add another.",
2031 cap,
2032 ));
2033 }
2034 }
2035
2036 let builder = build_pack_event(&to_save)?;
2037 crate::sign_and_send(&client, builder).await
2038 .map_err(|e| format!("publish kind 30030: {}", e))?;
2039
2040
2041 save_pack(&to_save)?;
2042
2043 let mut subs = load_subscriptions()?;
2046 if !subs.iter().any(|a| a == &raw_addr) {
2047 subs.push(raw_addr.clone());
2048 save_subscriptions(&subs)?;
2049 republish_emoji_list_debounced();
2050 }
2051
2052 crate::traits::emit_event("emoji_packs_updated", &());
2053 crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
2054 to_save.identifier, to_save.emojis.len());
2055
2056 Ok(to_save)
2057 })
2058 .await
2059}
2060
2061pub async fn delete_own_pack(id: &str) -> Result<(), String> {
2065 crate::db::scoped(async move {
2066 let parsed = parse_naddr(id)?;
2067 let raw_addr = parsed.to_addr_string();
2068 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2069 if parsed.pubkey != my_pk {
2070 return Err("Cannot delete a pack you don't own".to_string());
2071 }
2072 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2073
2074 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2075 .tag(Tag::custom("d", [parsed.identifier.clone()]));
2076 crate::sign_and_send(&client, builder).await
2077 .map_err(|e| format!("publish empty kind 30030: {}", e))?;
2078
2079
2080 {
2084 let mut conn = crate::db::get_write_connection_guard_static()?;
2085 let tx = conn.transaction()
2086 .map_err(|e| format!("begin delete tx: {}", e))?;
2087 tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
2088 rusqlite::params![raw_addr])
2089 .map_err(|e| format!("drop subscription: {}", e))?;
2090 tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
2091 rusqlite::params![raw_addr])
2092 .map_err(|e| format!("drop pack row: {}", e))?;
2093 tx.commit()
2094 .map_err(|e| format!("commit delete tx: {}", e))?;
2095 }
2096
2097 republish_emoji_list_debounced();
2098 crate::traits::emit_event("emoji_packs_updated", &());
2099 crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
2100 Ok(())
2101 })
2102 .await
2103}
2104
2105pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
2109 crate::db::scoped(async move {
2110 let raw_addr = parse_naddr(id)?.to_addr_string();
2111 let mut subs = load_subscriptions()?;
2112 let before = subs.len();
2113 subs.retain(|a| a != &raw_addr);
2114 if subs.len() == before {
2115 return Ok(()); }
2117 save_subscriptions(&subs)?;
2118 republish_emoji_list_debounced();
2119 crate::traits::emit_event("emoji_packs_updated", &());
2120 Ok(())
2121 })
2122 .await
2123}
2124
2125pub fn reorder_emoji_packs(ordered_ids: Vec<String>) -> Result<(), String> {
2134 let session = crate::db::current_session();
2135
2136 let mut real_addrs: Vec<String> = Vec::with_capacity(ordered_ids.len());
2137 let mut anchor = String::new();
2138 let mut marker_present = false;
2139 for id in &ordered_ids {
2140 if id == THEME_SLOT_TOKEN {
2141 marker_present = true;
2142 anchor = real_addrs.last().cloned().unwrap_or_default();
2143 } else {
2144 real_addrs.push(parse_naddr(id)?.to_addr_string());
2145 }
2146 }
2147
2148 if !session.is_live() {
2149 return Err("Account swapped during reorder — aborted".to_string());
2150 }
2151 save_subscriptions(&real_addrs)?;
2152 if marker_present {
2156 save_theme_slot_anchor(&anchor)?;
2157 }
2158 republish_emoji_list_debounced();
2159 crate::traits::emit_event("emoji_packs_updated", &());
2160 Ok(())
2161}
2162
2163pub fn get_theme_slot_anchor() -> Result<String, String> {
2167 let raw = load_theme_slot_anchor();
2168 if raw.is_empty() {
2169 return Ok(String::new());
2170 }
2171 Ok(naddr_from_addr(&raw).unwrap_or_default())
2172}
2173
2174#[cfg(test)]
2179mod tests {
2180 use super::*;
2181
2182 fn keys() -> Keys {
2183 Keys::generate()
2184 }
2185
2186 fn build_pack_event(
2187 k: &Keys,
2188 d: &str,
2189 title_tag: Option<(&str, &str)>,
2190 image_tag: Option<(&str, &str)>,
2191 desc_tag: Option<(&str, &str)>,
2192 emojis: &[(&str, &str)],
2193 ) -> Event {
2194 let mut tags: Vec<Tag> = Vec::new();
2195 tags.push(Tag::custom("d", [d]));
2196 if let Some((key, val)) = title_tag {
2197 tags.push(Tag::custom(key, [val]));
2198 }
2199 if let Some((key, val)) = image_tag {
2200 tags.push(Tag::custom(key, [val]));
2201 }
2202 if let Some((key, val)) = desc_tag {
2203 tags.push(Tag::custom(key, [val]));
2204 }
2205 for (code, url) in emojis {
2206 tags.push(Tag::custom("emoji", [*code, *url]));
2207 }
2208 EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2209 .tags(tags)
2210 .finalize(k)
2211 .unwrap()
2212 }
2213
2214 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2215 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2216 crate::db::close_database();
2217 crate::db::clear_id_caches();
2220 let tmp = tempfile::tempdir().unwrap();
2221 let account = keys().public_key().to_bech32().unwrap();
2222 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2223 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
2224 crate::db::set_current_account(account.clone()).unwrap();
2225 crate::db::init_database(&account).unwrap();
2226 (tmp, guard)
2227 }
2228
2229 fn seed_pack(k: &Keys, d: &str, emojis: &[(&str, &str)]) -> String {
2231 let ev = build_pack_event(k, d, None, None, None, emojis);
2232 let pack = parse_pack_from_event(&ev, None).unwrap();
2233 save_pack(&pack).unwrap();
2234 pack.addr()
2235 }
2236
2237 fn seed_usage(url: &str) {
2238 let conn = crate::db::get_write_connection_guard_static().unwrap();
2239 conn.execute(
2240 "INSERT INTO emoji_usage (kind, id, url, score, last_used) VALUES (1, ?1, ?1, 1.0, 0)",
2241 rusqlite::params![url],
2242 ).unwrap();
2243 }
2244
2245 fn usage_count() -> i64 {
2246 let conn = crate::db::get_db_connection_guard_static().unwrap();
2247 conn.query_row("SELECT COUNT(*) FROM emoji_usage", [], |r| r.get(0)).unwrap()
2248 }
2249
2250 fn pack_status(addr: &str) -> u8 {
2251 load_cached_pack(addr).unwrap().unwrap().status
2252 }
2253
2254 fn build_deletion_event(k: &Keys, addr: &str) -> Event {
2255 EventBuilder::new(Kind::EventDeletion, "")
2256 .tag(Tag::custom("a", [addr]))
2257 .finalize(k)
2258 .unwrap()
2259 }
2260
2261 #[test]
2262 fn classify_verdict_table() {
2263 let k = keys();
2264 let live = build_pack_event(&k, "p", None, None, None, &[("a", "https://e.x/a.png")]);
2265 let empty = build_pack_event(&k, "p", None, None, None, &[]);
2266 let addr = format!("30030:{}:p", k.public_key().to_hex());
2267 let del = build_deletion_event(&k, &addr);
2268
2269 let (o, p) = classify_pack(Some(&live), None, None, false);
2271 assert_eq!(o, PackFetchOutcome::Found);
2272 assert!(p.is_some());
2273
2274 let (o, _) = classify_pack(Some(&empty), None, None, true);
2276 assert_eq!(o, PackFetchOutcome::Tombstoned);
2277
2278 let (o, _) = classify_pack(None, Some(&del), None, true);
2280 assert_eq!(o, PackFetchOutcome::Tombstoned);
2281
2282 let ts = Timestamp::from_secs(1_700_000_000);
2285 let live_pinned = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2286 .tags(vec![
2287 Tag::custom("d", ["p"]),
2288 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2289 ])
2290 .custom_created_at(ts)
2291 .finalize(&k)
2292 .unwrap();
2293 let del_at = |secs: u64| {
2294 EventBuilder::new(Kind::EventDeletion, "")
2295 .tag(Tag::custom("a", [addr.as_str()]))
2296 .custom_created_at(Timestamp::from_secs(secs))
2297 .finalize(&k)
2298 .unwrap()
2299 };
2300
2301 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_000)), None, false);
2304 assert_eq!(o, PackFetchOutcome::Found);
2305 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_001)), None, false);
2307 assert_eq!(o, PackFetchOutcome::Tombstoned);
2308 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_699_999_999)), None, false);
2310 assert_eq!(o, PackFetchOutcome::Found);
2311
2312 let (o, _) = classify_pack(None, None, None, true);
2314 assert_eq!(o, PackFetchOutcome::CleanMiss);
2315 let (o, _) = classify_pack(None, None, None, false);
2316 assert_eq!(o, PackFetchOutcome::Unreachable);
2317 }
2318
2319 #[test]
2320 fn classify_invalid_emojis_is_not_a_tombstone() {
2321 let k = keys();
2324 let ev = build_pack_event(&k, "weird", None, None, None, &[("bad!code", "https://e.x/a.png")]);
2325 assert!(parse_pack_from_event(&ev, None).is_none(), "precondition: unparseable");
2326 let (o, _) = classify_pack(Some(&ev), None, None, true);
2327 assert_eq!(o, PackFetchOutcome::Unreachable);
2328 }
2329
2330 #[test]
2331 fn absence_gate_table() {
2332 let some_writes = vec![RelayUrl::parse("wss://relay.author.example").unwrap()];
2333 let no_writes: Vec<RelayUrl> = Vec::new();
2334
2335 assert!(!absence_is_clean(false, 5, Some(&no_writes), true, 1));
2337 assert!(!absence_is_clean(true, 1, Some(&no_writes), true, 1));
2338 assert!(!absence_is_clean(true, 3, None, true, 1));
2341 assert!(absence_is_clean(true, 2, Some(&no_writes), false, 0));
2343 assert!(!absence_is_clean(true, 3, Some(&some_writes), false, 0));
2345 assert!(!absence_is_clean(true, 3, Some(&some_writes), true, 0));
2346 assert!(absence_is_clean(true, 3, Some(&some_writes), true, 1));
2347 }
2348
2349 #[test]
2350 fn deletion_matching_is_author_bound() {
2351 let author = keys();
2352 let stranger = keys();
2353 let addr = format!("30030:{}:p", author.public_key().to_hex());
2354 let forged = build_deletion_event(&stranger, &addr);
2356 assert!(!deletion_matches(&forged, &author.public_key(), &addr));
2357 let real = build_deletion_event(&author, &addr);
2358 assert!(deletion_matches(&real, &author.public_key(), &addr));
2359 }
2360
2361 #[test]
2362 fn tombstone_revokes_immediately_and_purges_frecency() {
2363 let (_tmp, _guard) = init_test_db();
2364 let k = keys();
2365 let addr = seed_pack(&k, "deadpack", &[("boom", "https://e.x/boom.png")]);
2366 seed_usage("https://e.x/boom.png");
2367 seed_usage("https://e.x/unrelated.png");
2368
2369 let changed = apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2370 assert!(changed, "tombstone must flip status in one sweep");
2371 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2372 assert_eq!(usage_count(), 1, "only the dead pack's usage rows purge");
2373 }
2374
2375 #[test]
2376 fn found_self_heals_from_revoked() {
2377 let (_tmp, _guard) = init_test_db();
2378 let k = keys();
2379 let addr = seed_pack(&k, "phoenix", &[("rise", "https://e.x/r.png")]);
2380 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2381 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2382
2383 let changed = apply_pack_health(&addr, &PackFetchOutcome::Found, 2_000).unwrap();
2384 assert!(changed);
2385 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "a republished pack revives");
2386 }
2387
2388 #[test]
2389 fn clean_miss_gauntlet_rate_limit_and_promotion() {
2390 let (_tmp, _guard) = init_test_db();
2391 let k = keys();
2392 let addr = seed_pack(&k, "fading", &[("bye", "https://e.x/bye.png")]);
2393 let t0: i64 = 1_000_000;
2394 let h = 3_600;
2395
2396 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0).unwrap());
2398 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + h).unwrap());
2399 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 13 * h).unwrap());
2401 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 26 * h).unwrap());
2402 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "count alone must not promote");
2403 let changed = apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 49 * h).unwrap();
2405 assert!(changed);
2406 assert_eq!(pack_status(&addr), PACK_STATUS_MISSING);
2407 }
2408
2409 #[test]
2410 fn unreachable_never_moves_counters() {
2411 let (_tmp, _guard) = init_test_db();
2412 let k = keys();
2413 let addr = seed_pack(&k, "offline", &[("zz", "https://e.x/z.png")]);
2414 for t in [1_000i64, 200_000, 400_000, 800_000] {
2415 assert!(!apply_pack_health(&addr, &PackFetchOutcome::Unreachable, t).unwrap());
2416 }
2417 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE);
2418 let conn = crate::db::get_db_connection_guard_static().unwrap();
2419 let miss: i64 = conn.query_row(
2420 "SELECT miss_count FROM emoji_packs WHERE addr = ?1",
2421 rusqlite::params![addr], |r| r.get(0),
2422 ).unwrap();
2423 assert_eq!(miss, 0, "offline sweeps must not accrue misses");
2424 }
2425
2426 #[test]
2427 fn revoked_ignores_clean_misses() {
2428 let (_tmp, _guard) = init_test_db();
2429 let k = keys();
2430 let addr = seed_pack(&k, "sealed", &[("x", "https://e.x/x.png")]);
2431 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2432 for t in [100_000i64, 300_000, 600_000] {
2433 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t).unwrap());
2434 }
2435 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED, "misses can't downgrade a tombstone verdict");
2436 }
2437
2438 #[test]
2439 fn empty_pack_event_is_the_tombstone_shape() {
2440 let k = keys();
2444 let ev = build_pack_event(&k, "gone", None, None, None, &[]);
2445 assert!(parse_pack_from_event(&ev, None).is_none());
2446 }
2447
2448 #[test]
2449 fn parse_pack_reads_nip51_spec_tags() {
2450 let k = keys();
2451 let ev = build_pack_event(
2452 &k, "myPack",
2453 Some(("title", "Spec Pack")),
2454 Some(("image", "https://example.com/p.png")),
2455 Some(("description", "specd")),
2456 &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
2457 );
2458 let pack = parse_pack_from_event(&ev, None).unwrap();
2459 assert_eq!(pack.identifier, "myPack");
2460 assert_eq!(pack.title, "Spec Pack");
2461 assert_eq!(pack.image_url, "https://example.com/p.png");
2462 assert_eq!(pack.description, "specd");
2463 assert_eq!(pack.emojis.len(), 2);
2464 assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
2465 }
2466
2467 #[test]
2468 fn own_pack_event_carries_the_vector_client_tag() {
2469 let k = keys();
2472 let ev = build_pack_event(&k, "myPack", Some(("title", "Mine")), None, None, &[("smile", "https://e.x/s.png")]);
2473 let pack = parse_pack_from_event(&ev, None).unwrap();
2474 let built = super::build_pack_event(&pack).unwrap().finalize(&k).unwrap();
2475 let has_client = built.tags.iter().any(|t| {
2476 let s = t.as_slice();
2477 s.first().map(String::as_str) == Some("client") && s.get(1).map(String::as_str) == Some("vector")
2478 });
2479 assert!(has_client, "own pack event must carry the [\"client\",\"vector\"] tag");
2480 }
2481
2482 #[test]
2483 fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
2484 let k = keys();
2485 let ev = build_pack_event(
2486 &k, "ditto",
2487 Some(("name", "Ditto Pack")),
2488 Some(("picture", "https://example.com/d.png")),
2489 Some(("about", "ditto-style")),
2490 &[("yes", "https://e.x/y.png")],
2491 );
2492 let pack = parse_pack_from_event(&ev, None).unwrap();
2493 assert_eq!(pack.title, "Ditto Pack");
2494 assert_eq!(pack.image_url, "https://example.com/d.png");
2495 assert_eq!(pack.description, "ditto-style");
2496 }
2497
2498 #[test]
2499 fn parse_pack_prefers_spec_tags_over_ditto() {
2500 let k = keys();
2501 let mut tags: Vec<Tag> = vec![
2502 Tag::custom("d", ["both"]),
2503 Tag::custom("title", ["SpecTitle"]),
2504 Tag::custom("name", ["DittoName"]),
2505 Tag::custom("image", ["spec.png"]),
2506 Tag::custom("picture", ["ditto.png"]),
2507 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2508 ];
2509 tags.extend(std::iter::empty());
2510 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2511 .tags(tags).finalize(&k).unwrap();
2512 let pack = parse_pack_from_event(&ev, None).unwrap();
2513 assert_eq!(pack.title, "SpecTitle");
2514 assert_eq!(pack.image_url, "spec.png");
2515 }
2516
2517 #[test]
2518 fn parse_pack_returns_none_without_d_tag() {
2519 let k = keys();
2520 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2521 .tags(vec![
2522 Tag::custom("title", ["No D"]),
2523 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2524 ])
2525 .finalize(&k).unwrap();
2526 assert!(parse_pack_from_event(&ev, None).is_none());
2527 }
2528
2529 #[test]
2530 fn parse_pack_returns_none_when_no_valid_emojis() {
2531 let k = keys();
2532 let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
2533 assert!(parse_pack_from_event(&ev, None).is_none());
2534 }
2535
2536 #[test]
2537 fn parse_pack_rejects_invalid_shortcodes() {
2538 let k = keys();
2539 let ev = build_pack_event(
2540 &k, "mix", Some(("title", "Mix")), None, None,
2541 &[("ok_name", "https://e.x/a.png"),
2542 ("bad name", "https://e.x/b.png"),
2543 ("colons:no", "https://e.x/c.png"),
2544 ("", "https://e.x/d.png"),
2545 ("dash-ok", "https://e.x/e.png")],
2546 );
2547 let pack = parse_pack_from_event(&ev, None).unwrap();
2548 let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
2549 assert_eq!(codes, vec!["ok_name", "dash-ok"]);
2550 }
2551
2552 #[test]
2553 fn parse_pack_dedupes_shortcodes_first_wins() {
2554 let k = keys();
2555 let ev = build_pack_event(
2556 &k, "dup", Some(("title", "Dup")), None, None,
2557 &[("smile", "https://e.x/first.png"),
2558 ("smile", "https://e.x/second.png")],
2559 );
2560 let pack = parse_pack_from_event(&ev, None).unwrap();
2561 assert_eq!(pack.emojis.len(), 1);
2562 assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
2563 }
2564
2565 #[test]
2566 fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
2567 let k = keys();
2568 let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
2569 &[("a", "https://e.x/a.png")]);
2570 let my_hex = k.public_key().to_hex();
2571 let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
2572 assert!(pack_mine.is_own);
2573
2574 let stranger = keys().public_key().to_hex();
2575 let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
2576 assert!(!pack_other.is_own);
2577 }
2578
2579 #[test]
2580 fn pack_address_to_string_round_trips() {
2581 let k = keys();
2582 let hex = k.public_key().to_hex();
2583 let addr = PackAddress {
2584 kind: 30030,
2585 pubkey: k.public_key(),
2586 identifier: "myPack".to_string(),
2587 };
2588 assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
2589 let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
2590 assert_eq!(parsed, addr);
2591 }
2592
2593 #[test]
2594 fn parse_naddr_round_trips_kind_30030_coordinate() {
2595 let k = keys();
2598 let coord = nostr_sdk::prelude::nip01::Coordinate {
2599 kind: Kind::Custom(30030),
2600 public_key: k.public_key(),
2601 identifier: "trip".to_string(),
2602 };
2603 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2604 coordinate: coord,
2605 relays: Vec::new(),
2606 };
2607 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2608 let parsed = parse_naddr(&naddr).unwrap();
2609 assert_eq!(parsed.kind, 30030);
2610 assert_eq!(parsed.pubkey, k.public_key());
2611 assert_eq!(parsed.identifier, "trip");
2612 }
2613
2614 #[test]
2615 fn parse_naddr_rejects_non_30030_kinds() {
2616 let k = keys();
2617 let coord = nostr_sdk::prelude::nip01::Coordinate {
2618 kind: Kind::Custom(30023), public_key: k.public_key(),
2620 identifier: "essay".to_string(),
2621 };
2622 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2623 coordinate: coord,
2624 relays: Vec::new(),
2625 };
2626 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2627 let err = parse_naddr(&naddr).unwrap_err();
2628 assert!(err.contains("expected kind 30030"),
2629 "expected kind-rejection error, got: {}", err);
2630 }
2631
2632 #[test]
2633 fn parse_naddr_strips_nostr_uri_prefix() {
2634 let k = keys();
2635 let coord = nostr_sdk::prelude::nip01::Coordinate {
2636 kind: Kind::Custom(30030),
2637 public_key: k.public_key(),
2638 identifier: "prefixed".to_string(),
2639 };
2640 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2641 coordinate: coord,
2642 relays: Vec::new(),
2643 };
2644 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2645 let with_prefix = format!("nostr:{}", naddr);
2646 let parsed = parse_naddr(&with_prefix).unwrap();
2647 assert_eq!(parsed.identifier, "prefixed");
2648 }
2649
2650 #[test]
2651 fn parse_naddr_rejects_garbage_input() {
2652 assert!(parse_naddr("not an naddr").is_err());
2653 assert!(parse_naddr("naddr1invalid").is_err());
2654 assert!(parse_naddr("").is_err());
2655 }
2656
2657 #[test]
2658 fn parse_pack_address_round_trips_valid_input() {
2659 let k = keys();
2660 let hex = k.public_key().to_hex();
2661 let addr = format!("30030:{}:myId", hex);
2662 let parsed = parse_pack_address(&addr).unwrap();
2663 assert_eq!(parsed.kind, 30030);
2664 assert_eq!(parsed.pubkey, k.public_key());
2665 assert_eq!(parsed.identifier, "myId");
2666 }
2667
2668 #[test]
2669 fn parse_pack_address_rejects_wrong_kind() {
2670 let hex = keys().public_key().to_hex();
2671 let addr = format!("10030:{}:x", hex);
2672 assert!(parse_pack_address(&addr).is_err());
2673 }
2674
2675 #[test]
2676 fn parse_pack_address_rejects_malformed_pubkey() {
2677 let addr = "30030:not-hex:x".to_string();
2678 assert!(parse_pack_address(&addr).is_err());
2679 }
2680
2681 #[test]
2682 fn parse_pack_address_preserves_colons_in_identifier() {
2683 let hex = keys().public_key().to_hex();
2685 let addr = format!("30030:{}:id:with:colons", hex);
2686 let parsed = parse_pack_address(&addr).unwrap();
2687 assert_eq!(parsed.identifier, "id:with:colons");
2688 }
2689
2690 #[test]
2691 fn parse_inner_tag_list_extracts_valid_a_tags() {
2692 let hex_a = keys().public_key().to_hex();
2696 let hex_b = keys().public_key().to_hex();
2697 let plaintext = format!(
2698 r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
2699 a = hex_a,
2700 b = hex_b,
2701 );
2702 let addrs = parse_inner_tag_list(&plaintext);
2703 assert_eq!(addrs.len(), 2);
2704 assert_eq!(addrs[0].identifier, "packA");
2705 assert_eq!(addrs[1].identifier, "packB");
2706 }
2707
2708 #[test]
2709 fn parse_inner_tag_list_returns_empty_on_malformed_json() {
2710 assert!(parse_inner_tag_list("not json").is_empty());
2711 assert!(parse_inner_tag_list("").is_empty());
2712 }
2713
2714 #[test]
2715 fn theme_slot_marker_round_trips_through_publish_and_parse() {
2716 let a = keys().public_key().to_hex();
2719 let b = keys().public_key().to_hex();
2720 let c = keys().public_key().to_hex();
2721 let addr_a = format!("30030:{}:packA", a);
2722 let addr_b = format!("30030:{}:packB", b);
2723 let addr_c = format!("30030:{}:packC", c);
2724 let addrs = vec![addr_a.clone(), addr_b.clone(), addr_c.clone()];
2725
2726 let inner = build_inner_tags_with_marker(&addrs, &addr_b);
2727 let marker_count = inner.iter()
2728 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2729 .count();
2730 assert_eq!(marker_count, 1, "exactly one marker is emitted");
2731
2732 let plaintext = serde_json::to_string(&inner).unwrap();
2733 let (parsed, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
2734 assert_eq!(parsed, addrs, "addr order survives the round trip");
2735 assert_eq!(anchor.as_deref(), Some(addr_b.as_str()), "anchor is the 2nd pack");
2736
2737 let old = format!(r#"[["a","{}"],["a","{}"]]"#, addr_a, addr_b);
2739 let (old_addrs, old_anchor) = parse_inner_tag_list_with_anchor(&old);
2740 assert_eq!(old_addrs, vec![addr_a.clone(), addr_b.clone()]);
2741 assert_eq!(old_anchor, None, "a list without the marker has no anchor");
2742
2743 let top = build_inner_tags_with_marker(&addrs, "");
2745 assert_eq!(top[0], vec![THEME_SLOT_TOKEN.to_string()]);
2746 let (_, top_anchor) =
2747 parse_inner_tag_list_with_anchor(&serde_json::to_string(&top).unwrap());
2748 assert_eq!(top_anchor.as_deref(), Some(""), "top slot = empty anchor");
2749
2750 let gone = format!("30030:{}:ghost", keys().public_key().to_hex());
2752 let fallback = build_inner_tags_with_marker(&addrs, &gone);
2753 assert_eq!(fallback[0], vec![THEME_SLOT_TOKEN.to_string()]);
2754 let fb_markers = fallback.iter()
2755 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2756 .count();
2757 assert_eq!(fb_markers, 1, "still exactly one marker on the fallback path");
2758 }
2759
2760 #[test]
2761 fn shortcode_validator_accepts_alphanum_dash_underscore() {
2762 assert!(is_valid_shortcode("smile"));
2763 assert!(is_valid_shortcode("smile_face"));
2764 assert!(is_valid_shortcode("smile-face"));
2765 assert!(is_valid_shortcode("Smile2"));
2766 assert!(!is_valid_shortcode(""));
2767 assert!(!is_valid_shortcode("smile face"));
2768 assert!(!is_valid_shortcode("smile:face"));
2769 assert!(!is_valid_shortcode("😀"));
2770 assert!(!is_valid_shortcode("love~2"));
2773 }
2774
2775 #[test]
2776 fn resolve_token_disambiguates_duplicate_shortcodes() {
2777 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
2779 by_code.insert(
2780 "love".to_string(),
2781 vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
2782 );
2783 by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
2784
2785 assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
2787 assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
2789 assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
2790 assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
2792 assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
2793 assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
2794 assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
2796 assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
2797 }
2798
2799 #[test]
2800 fn message_emoji_tags_accept_disambiguation_separator() {
2801 let tags = vec![
2803 vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
2804 vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
2805 ];
2806 let out = crate::types::EmojiTag::extract_from_stored(&tags);
2807 assert_eq!(out.len(), 1);
2808 assert_eq!(out[0].shortcode, "love~2");
2809 }
2810}