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
185static THEME_EMOJI_TAGS: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
190 std::sync::OnceLock::new();
191
192fn theme_emoji_tags() -> &'static std::sync::Mutex<Vec<(String, String)>> {
193 THEME_EMOJI_TAGS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
194}
195
196pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
199 if let Ok(mut g) = theme_emoji_tags().lock() {
200 *g = tags;
201 }
202}
203
204pub fn effective_display_cap() -> usize {
210 EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
211}
212
213fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
218 if let Some((base, suffix)) = token.rsplit_once('~') {
219 let n: usize = suffix.parse().ok()?;
220 if n == 0 { return None; }
221 return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
222 }
223 by_code.get(token).and_then(|urls| urls.first().cloned())
224}
225
226pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
235 if content.is_empty() || !content.contains(':') {
236 return Vec::new();
237 }
238
239 let cap = effective_display_cap();
241 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
242
243 if let Ok(conn) = crate::db::get_db_connection_guard_static() {
249 if let Ok(mut stmt) = conn.prepare(
250 "SELECT p.addr, i.shortcode, i.url
251 FROM emoji_pack_items i
252 INNER JOIN emoji_packs p ON p.addr = i.pack_addr
253 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
254 WHERE p.status = 0
255 ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
256 ) {
257 if let Ok(rows) = stmt.query_map([], |row| {
258 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
259 }) {
260 let mut per_pack: HashMap<String, usize> = HashMap::new();
263 for (addr, code, url) in rows.flatten() {
264 let n = per_pack.entry(addr).or_insert(0);
265 if *n >= cap { continue; }
266 *n += 1;
267 by_code.entry(code).or_default().push(url);
268 }
269 }
270 }
271 }
272
273 if let Ok(theme) = theme_emoji_tags().lock() {
277 for (code, url) in theme.iter() {
278 by_code.entry(code.clone()).or_default().push(url.clone());
279 }
280 }
281
282 if by_code.is_empty() {
283 return Vec::new();
284 }
285
286 for urls in by_code.values_mut() {
289 urls.sort();
290 urls.dedup();
291 }
292
293 let mut out: Vec<crate::types::EmojiTag> = Vec::new();
294 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
295 let bytes = content.as_bytes();
296 let mut i = 0;
297 while i < bytes.len() {
298 if bytes[i] == b':' {
299 let start = i + 1;
300 let mut j = start;
301 while j < bytes.len() {
302 let c = bytes[j];
303 let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
305 if !ok { break; }
306 j += 1;
307 }
308 if j > start && j < bytes.len() && bytes[j] == b':' {
309 if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
310 if !seen.contains(token) {
311 if let Some(url) = resolve_emoji_token(&by_code, token) {
312 out.push(crate::types::EmojiTag {
313 shortcode: token.to_string(),
314 url,
315 });
316 seen.insert(token.to_string());
317 }
318 }
319 }
320 i = j + 1;
321 continue;
322 }
323 }
324 i += 1;
325 }
326 out
327}
328
329fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
332 for key in keys {
333 for tag in tags.iter() {
334 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
335 if parts.len() >= 2 && parts[0] == *key {
336 return Some(parts[1].to_string());
337 }
338 }
339 }
340 None
341}
342
343pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
346 if event.kind.as_u16() != KIND_EMOJI_SET {
347 return None;
348 }
349
350 let identifier = first_tag(&event.tags, &["d"])?;
351 let pubkey = event.pubkey.to_hex();
352 let addr = build_pack_addr(&pubkey, &identifier);
353 let id = match naddr_from_addr(&addr) {
354 Ok(s) => s,
355 Err(e) => {
356 crate::log_warn!(
357 "[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
358 addr, e,
359 );
360 return None;
361 }
362 };
363
364 let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
365 let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
366 let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
367
368 let mut emojis = Vec::new();
369 let mut seen = std::collections::HashSet::new();
370 for tag in event.tags.iter() {
371 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
372 if parts.len() >= 3 && parts[0] == "emoji" {
373 let shortcode = parts[1];
374 if !is_valid_shortcode(shortcode) { continue; }
375 let url = parts[2].trim();
379 if url.is_empty() { continue; }
380 if !seen.insert(shortcode.to_string()) { continue; }
381 emojis.push(PackEmoji {
382 shortcode: shortcode.to_string(),
383 url: url.to_string(),
384 sha256: None,
385 });
386 }
387 }
388
389 if emojis.is_empty() {
390 return None;
391 }
392
393 let is_own = my_pubkey_hex.map_or(false, |me| me == pubkey);
394
395 Some(EmojiPack {
396 id,
397 pubkey,
398 identifier,
399 title,
400 image_url,
401 description,
402 emojis,
403 is_own,
404 updated_at: event.created_at.as_secs(),
405 status: PACK_STATUS_ACTIVE,
406 })
407}
408
409#[derive(Debug, Clone, PartialEq)]
411pub struct PackAddress {
412 pub kind: u16,
413 pub pubkey: PublicKey,
414 pub identifier: String,
415}
416
417pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
421 let mut parts = addr.splitn(3, ':');
422 let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
423 let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
424 let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
425
426 let kind: u16 = kind_str.parse()
427 .map_err(|_| format!("invalid kind: {}", kind_str))?;
428 if kind != KIND_EMOJI_SET {
429 return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
430 }
431 let pubkey = PublicKey::from_hex(pubkey_str)
432 .map_err(|e| format!("invalid pubkey: {}", e))?;
433
434 Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
435}
436
437impl PackAddress {
438 pub fn to_addr_string(&self) -> String {
443 debug_assert_eq!(self.kind, KIND_EMOJI_SET,
444 "PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
445 build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
446 }
447}
448
449pub async fn share_naddr(naddr: &str) -> Result<String, String> {
456 let coordinate = match Nip19::from_bech32(naddr) {
457 Ok(Nip19::Coordinate(c)) => c.coordinate,
458 _ => return Err("not a pack naddr".to_string()),
459 };
460 if coordinate.kind != Kind::Custom(KIND_EMOJI_SET) {
461 return Err("not an emoji pack coordinate".to_string());
462 }
463 let client = nostr_client().ok_or_else(|| "Nostr client not initialized".to_string())?;
464 let mut relays = fetch_author_write_relays(&client, coordinate.public_key).await;
465 if relays.is_empty() {
466 relays = client
469 .relays()
470 .await
471 .into_iter()
472 .filter(|(_, r)| r.capabilities().load().can_write())
473 .map(|(url, _)| url)
474 .collect();
475 }
476 relays.truncate(3);
477 if relays.is_empty() {
478 return Ok(naddr.to_string());
479 }
480 Nip19::Coordinate(Nip19Coordinate { coordinate, relays })
481 .to_bech32()
482 .map_err(|e| format!("encode naddr: {}", e))
483}
484
485pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
489 let parsed = parse_pack_address(addr)?;
490 let coord = nostr_sdk::prelude::nip01::Coordinate {
491 kind: Kind::Custom(parsed.kind),
492 public_key: parsed.pubkey,
493 identifier: parsed.identifier,
494 };
495 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
496 coordinate: coord,
497 relays: Vec::new(),
498 };
499 nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
500 .to_bech32()
501 .map_err(|e| format!("encode naddr: {}", e))
502}
503
504pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
508 let trimmed = naddr.trim().trim_start_matches("nostr:");
509 let parsed = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed)
510 .map_err(|e| format!("invalid naddr: {}", e))?;
511 let coord = match parsed {
512 nostr_sdk::prelude::nip19::Nip19::Coordinate(c) => c,
513 _ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
514 };
515 let kind = coord.kind.as_u16();
516 if kind != KIND_EMOJI_SET {
517 return Err(format!(
518 "expected kind {} (emoji set), got {}",
519 KIND_EMOJI_SET, kind,
520 ));
521 }
522 Ok(PackAddress {
523 kind,
524 pubkey: coord.public_key,
525 identifier: coord.identifier.clone(),
526 })
527}
528
529const THEME_SLOT_TOKEN: &str = "theme_slot";
535
536fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
541 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
542 Ok(v) => v,
543 Err(e) => {
544 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
545 return Vec::new();
546 }
547 };
548 inner.into_iter()
549 .filter_map(|tup| {
550 if tup.len() >= 2 && tup[0] == "a" {
551 parse_pack_address(&tup[1]).ok()
552 } else {
553 None
554 }
555 })
556 .collect()
557}
558
559fn parse_inner_tag_list_with_anchor(plaintext: &str) -> (Vec<String>, Option<String>) {
566 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
567 Ok(v) => v,
568 Err(e) => {
569 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
570 return (Vec::new(), None);
571 }
572 };
573 let mut addrs: Vec<String> = Vec::new();
574 let mut anchor: Option<String> = None;
575 for tup in inner {
576 if tup.len() >= 2 && tup[0] == "a" {
577 if let Ok(pa) = parse_pack_address(&tup[1]) {
580 addrs.push(pa.to_addr_string());
581 }
582 } else if anchor.is_none() && tup.first().map(String::as_str) == Some(THEME_SLOT_TOKEN) {
583 anchor = Some(addrs.last().cloned().unwrap_or_default());
586 }
587 }
588 (addrs, anchor)
589}
590
591pub async fn decrypt_subscribed_addresses(
599 client: &Client,
600 my_pk: &PublicKey,
601 event: &Event,
602) -> Vec<PackAddress> {
603 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
604 Some(plaintext) => parse_inner_tag_list(&plaintext),
605 None => Vec::new(),
606 }
607}
608
609async fn decrypt_emoji_list_plaintext(
612 _client: &Client,
613 my_pk: &PublicKey,
614 event: &Event,
615) -> Option<String> {
616 if event.content.is_empty() {
617 return None;
618 }
619 let signer = match crate::signer::active_signer() {
620 Ok(s) => s,
621 Err(e) => {
622 crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
623 return None;
624 }
625 };
626 match signer.nip44_decrypt_async(my_pk, &event.content).await {
627 Ok(p) => Some(p),
628 Err(e) => {
629 crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
630 None
631 }
632 }
633}
634
635async fn decrypt_subscribed_addresses_with_anchor(
638 client: &Client,
639 my_pk: &PublicKey,
640 event: &Event,
641) -> (Vec<PackAddress>, Option<String>) {
642 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
643 Some(plaintext) => {
644 let (raw, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
645 let addrs = raw.iter().filter_map(|s| parse_pack_address(s).ok()).collect();
646 (addrs, anchor)
647 }
648 None => (Vec::new(), None),
649 }
650}
651
652pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
657 let mut conn = crate::db::get_write_connection_guard_static()?;
658 let tx = conn.transaction()
659 .map_err(|e| format!("Failed to start tx: {}", e))?;
660
661 let addr = pack.addr();
662 tx.execute(
663 "INSERT OR REPLACE INTO emoji_packs
664 (addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
665 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
666 rusqlite::params![
667 addr, pack.pubkey, pack.identifier,
668 pack.title, pack.image_url, pack.description,
669 pack.is_own as i32, pack.updated_at as i64,
670 ],
671 ).map_err(|e| format!("Failed to upsert pack: {}", e))?;
672
673 tx.execute(
677 "DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
678 rusqlite::params![addr],
679 ).map_err(|e| format!("Failed to clear pack items: {}", e))?;
680
681 for (pos, emoji) in pack.emojis.iter().enumerate() {
682 tx.execute(
683 "INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
684 VALUES (?1, ?2, ?3, ?4, ?5)",
685 rusqlite::params![
686 addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
687 ],
688 ).map_err(|e| format!("Failed to insert pack item: {}", e))?;
689 }
690
691 tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
692 Ok(())
693}
694
695pub fn apply_pack_health(addr: &str, outcome: &PackFetchOutcome, now: i64) -> Result<bool, String> {
712 use rusqlite::OptionalExtension;
713 if matches!(outcome, PackFetchOutcome::Unreachable) {
714 return Ok(false);
715 }
716 let mut conn = crate::db::get_write_connection_guard_static()?;
720 let tx = conn
721 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
722 .map_err(|e| format!("begin pack health tx: {}", e))?;
723
724 let row = tx
725 .query_row(
726 "SELECT status, miss_count, first_missed_at, last_miss_counted_at
727 FROM emoji_packs WHERE addr = ?1",
728 rusqlite::params![addr],
729 |r| {
730 Ok((
731 r.get::<_, i64>(0)?,
732 r.get::<_, i64>(1)?,
733 r.get::<_, i64>(2)?,
734 r.get::<_, i64>(3)?,
735 ))
736 },
737 )
738 .optional()
739 .map_err(|e| format!("read pack health: {}", e))?;
740 let Some((status, miss_count, first_missed_at, last_miss_counted_at)) = row else {
741 return Ok(false);
742 };
743
744 let (new_status, new_miss, new_first, new_last) = match outcome {
745 PackFetchOutcome::Found => (PACK_STATUS_ACTIVE as i64, 0, 0, 0),
746 PackFetchOutcome::Tombstoned => (PACK_STATUS_REVOKED as i64, 0, 0, 0),
747 PackFetchOutcome::CleanMiss => {
748 if status == PACK_STATUS_REVOKED as i64 {
749 (status, miss_count, first_missed_at, last_miss_counted_at)
750 } else if now - last_miss_counted_at < PACK_MISS_RATELIMIT_SECS {
751 (status, miss_count, first_missed_at, last_miss_counted_at)
752 } else {
753 let first = if miss_count == 0 { now } else { first_missed_at };
754 let count = miss_count + 1;
755 let promoted = count >= PACK_MISS_PROMOTE_COUNT
756 && now - first >= PACK_MISS_PROMOTE_SECS;
757 (
758 if promoted { PACK_STATUS_MISSING as i64 } else { status },
759 count,
760 first,
761 now,
762 )
763 }
764 }
765 PackFetchOutcome::Unreachable => unreachable!(),
766 };
767
768 let changed = new_status != status;
769 tx.execute(
770 "UPDATE emoji_packs SET status = ?2, miss_count = ?3, first_missed_at = ?4,
771 last_miss_counted_at = ?5,
772 status_changed_at = CASE WHEN status != ?2 THEN ?6 ELSE status_changed_at END
773 WHERE addr = ?1",
774 rusqlite::params![addr, new_status, new_miss, new_first, new_last, now],
775 )
776 .map_err(|e| format!("write pack health: {}", e))?;
777
778 if changed && new_status != PACK_STATUS_ACTIVE as i64 {
781 tx.execute(
782 "DELETE FROM emoji_usage WHERE kind = 1 AND url IN
783 (SELECT url FROM emoji_pack_items WHERE pack_addr = ?1)",
784 rusqlite::params![addr],
785 )
786 .map_err(|e| format!("purge dead pack usage: {}", e))?;
787 }
788
789 tx.commit().map_err(|e| format!("commit pack health: {}", e))?;
790 Ok(changed)
791}
792
793pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
794 let mut conn = crate::db::get_write_connection_guard_static()?;
795 let tx = conn.transaction()
796 .map_err(|e| format!("Failed to start tx: {}", e))?;
797
798 tx.execute("DELETE FROM emoji_pack_subscriptions", [])
799 .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
800
801 let now = std::time::SystemTime::now()
802 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
803 for (pos, addr) in addrs.iter().enumerate() {
807 tx.execute(
808 "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at, position)
809 VALUES (?1, ?2, ?3)",
810 rusqlite::params![addr, now, pos as i64],
811 ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
812 }
813
814 tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
815 Ok(())
816}
817
818pub fn load_subscriptions() -> Result<Vec<String>, String> {
819 let conn = crate::db::get_db_connection_guard_static()?;
820 let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY position ASC, subscribed_at ASC")
821 .map_err(|e| format!("prepare: {}", e))?;
822 let rows = stmt.query_map([], |row| row.get::<_, String>(0))
823 .map_err(|e| format!("query: {}", e))?;
824 let mut out = Vec::new();
825 for r in rows {
826 out.push(r.map_err(|e| format!("row: {}", e))?);
827 }
828 Ok(out)
829}
830
831pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
836 let conn = crate::db::get_db_connection_guard_static()?;
837
838 let mut pack = match conn.query_row(
839 "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at, status
840 FROM emoji_packs WHERE addr = ?1",
841 rusqlite::params![addr],
842 |row| {
843 Ok(EmojiPack {
844 id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
845 pubkey: row.get(0)?,
846 identifier: row.get(1)?,
847 title: row.get(2)?,
848 image_url: row.get(3)?,
849 description: row.get(4)?,
850 is_own: row.get::<_, i32>(5)? != 0,
851 updated_at: row.get::<_, i64>(6)? as u64,
852 emojis: Vec::new(),
853 status: row.get::<_, i64>(7)? as u8,
854 })
855 },
856 ) {
857 Ok(p) => p,
858 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
859 Err(e) => return Err(format!("query cached pack: {}", e)),
860 };
861
862 let mut stmt = conn.prepare(
863 "SELECT shortcode, url, sha256 FROM emoji_pack_items
864 WHERE pack_addr = ?1 ORDER BY position ASC"
865 ).map_err(|e| format!("prepare items: {}", e))?;
866 let rows = stmt.query_map(rusqlite::params![addr], |row| {
867 Ok(PackEmoji {
868 shortcode: row.get(0)?,
869 url: row.get(1)?,
870 sha256: row.get(2)?,
871 })
872 }).map_err(|e| format!("query items: {}", e))?;
873 for r in rows {
874 pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
875 }
876
877 Ok(Some(pack))
878}
879
880pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
885 let conn = crate::db::get_db_connection_guard_static()?;
886
887 let mut packs: HashMap<String, EmojiPack> = HashMap::new();
888 let mut order: Vec<String> = Vec::new();
889
890 {
891 let mut stmt = conn.prepare(
898 "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at, p.status
899 FROM emoji_packs p
900 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
901 ORDER BY s.position ASC"
902 ).map_err(|e| format!("prepare packs: {}", e))?;
903
904 let rows = stmt.query_map([], |row| {
905 let raw_addr: String = row.get(0)?;
909 let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
910 Ok((raw_addr, EmojiPack {
911 id,
912 pubkey: row.get(1)?,
913 identifier: row.get(2)?,
914 title: row.get(3)?,
915 image_url: row.get(4)?,
916 description: row.get(5)?,
917 is_own: row.get::<_, i32>(6)? != 0,
918 updated_at: row.get::<_, i64>(7)? as u64,
919 emojis: Vec::new(),
920 status: row.get::<_, i64>(8)? as u8,
921 }))
922 }).map_err(|e| format!("query packs: {}", e))?;
923
924 for r in rows {
925 let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
926 order.push(raw_addr.clone());
927 packs.insert(raw_addr, pack);
928 }
929 }
930
931 {
932 let mut stmt = conn.prepare(
933 "SELECT pack_addr, shortcode, url, sha256
934 FROM emoji_pack_items
935 ORDER BY pack_addr, position ASC"
936 ).map_err(|e| format!("prepare items: {}", e))?;
937
938 let rows = stmt.query_map([], |row| {
939 Ok((
940 row.get::<_, String>(0)?,
941 PackEmoji {
942 shortcode: row.get(1)?,
943 url: row.get(2)?,
944 sha256: row.get(3)?,
945 },
946 ))
947 }).map_err(|e| format!("query items: {}", e))?;
948
949 for r in rows {
950 let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
951 if let Some(p) = packs.get_mut(&addr) {
952 p.emojis.push(emoji);
953 }
954 }
955 }
956
957 Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
958}
959
960const NIP65_CACHE_TTL_SECS: u64 = 3600;
969const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
972const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
973
974#[derive(Clone)]
975struct CachedRelayList {
976 relays: Vec<RelayUrl>,
977 fetched_at: std::time::Instant,
978 empty: bool,
980 verified: bool,
984}
985
986static NIP65_CACHE: std::sync::OnceLock<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> =
987 std::sync::OnceLock::new();
988
989fn nip65_cache() -> &'static std::sync::RwLock<HashMap<PublicKey, CachedRelayList>> {
990 NIP65_CACHE.get_or_init(|| std::sync::RwLock::new(HashMap::new()))
991}
992
993fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
996 let cache = nip65_cache().read().ok()?;
997 let entry = cache.get(pubkey)?;
998 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
999 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1000 Some(entry.relays.clone())
1001 } else {
1002 None
1003 }
1004}
1005
1006fn cached_write_relays_verified(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1010 let cache = nip65_cache().read().ok()?;
1011 let entry = cache.get(pubkey)?;
1012 if !entry.verified {
1013 return None;
1014 }
1015 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1016 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1017 Some(entry.relays.clone())
1018 } else {
1019 None
1020 }
1021}
1022
1023pub fn clear_nip65_cache() {
1026 if let Ok(mut cache) = nip65_cache().write() {
1027 cache.clear();
1028 }
1029}
1030
1031fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>, verified: bool) {
1033 if let Ok(mut cache) = nip65_cache().write() {
1034 let empty = relays.is_empty();
1035 cache.insert(pubkey, CachedRelayList {
1036 relays,
1037 fetched_at: std::time::Instant::now(),
1038 empty,
1039 verified,
1040 });
1041 }
1042}
1043
1044fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
1048 let mut relays: Vec<RelayUrl> = Vec::new();
1049 for (url, marker) in nostr_sdk::prelude::nip65::extract_relay_list(ev) {
1050 match marker {
1051 None | Some(nostr_sdk::prelude::nip65::RelayMetadata::Write) => {
1052 if !relays.contains(&url) {
1053 relays.push(url.clone());
1054 }
1055 }
1056 Some(nostr_sdk::prelude::nip65::RelayMetadata::Read) => {}
1057 }
1058 }
1059 relays
1060}
1061
1062async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
1067 if let Some(relays) = cached_write_relays(&pubkey) {
1068 return relays;
1069 }
1070
1071 let filter = Filter::new()
1072 .author(pubkey)
1073 .kind(Kind::RelayList)
1074 .limit(1);
1075 let events = match client
1076 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1077 .await
1078 {
1079 Ok(evs) => evs,
1080 Err(_) => {
1081 cache_write_relays(pubkey, Vec::new(), false);
1082 return Vec::new();
1083 }
1084 };
1085
1086 let relays = events.into_iter()
1087 .max_by_key(|e| e.created_at)
1088 .map(|ev| extract_write_relays(&ev))
1089 .unwrap_or_default();
1090 cache_write_relays(pubkey, relays.clone(), true);
1091 relays
1092}
1093
1094async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
1100 let uncached: Vec<PublicKey> = authors.iter()
1101 .filter(|pk| cached_write_relays(pk).is_none())
1102 .copied()
1103 .collect();
1104 if uncached.is_empty() {
1105 return;
1106 }
1107
1108 let filter = Filter::new()
1109 .authors(uncached.iter().copied())
1110 .kind(Kind::RelayList);
1111 let events = match client
1112 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1113 .await
1114 {
1115 Ok(evs) => evs,
1116 Err(_) => return,
1119 };
1120
1121 let mut newest: HashMap<PublicKey, Event> = HashMap::new();
1123 for ev in events {
1124 match newest.get(&ev.pubkey) {
1125 Some(existing) if existing.created_at >= ev.created_at => {}
1126 _ => { newest.insert(ev.pubkey, ev); }
1127 }
1128 }
1129 for pk in uncached {
1130 let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
1131 cache_write_relays(pk, relays, true);
1132 }
1133}
1134
1135async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
1144 let filter = Filter::new()
1145 .author(addr.pubkey)
1146 .kind(Kind::Custom(KIND_EMOJI_SET))
1147 .identifier(&addr.identifier)
1148 .limit(1);
1149 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1150 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1151
1152 match client.fetch_events(filter.clone()).timeout(timeout).await {
1155 Ok(events) => {
1156 if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
1157 if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
1158 return Some(pack);
1159 }
1160 }
1161 }
1162 Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
1163 }
1164
1165 let outbox = fetch_author_write_relays(client, addr.pubkey).await;
1171 if outbox.is_empty() {
1172 return None;
1173 }
1174 fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
1175}
1176
1177async fn fetch_pack_via_isolated_client(
1181 relays: &[RelayUrl],
1182 filter: Filter,
1183 timeout: std::time::Duration,
1184 my_pubkey_hex: Option<&str>,
1185) -> Option<EmojiPack> {
1186 let scratch = crate::nostr_client_builder()
1187 .build();
1188 for r in relays {
1189 let _ = scratch.add_managed_relay(r.as_str()).await;
1190 }
1191 scratch.connect().await;
1192
1193 let result = scratch.fetch_events(filter).timeout(timeout).await;
1194 scratch.shutdown().await;
1196
1197 let events = match result {
1198 Ok(events) => events,
1199 Err(e) => {
1200 crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
1201 return None;
1202 }
1203 };
1204 let event = events.into_iter().max_by_key(|e| e.created_at)?;
1205 parse_pack_from_event(&event, my_pubkey_hex)
1206}
1207
1208fn event_coord(ev: &Event) -> Option<String> {
1227 let d = first_tag(&ev.tags, &["d"])?;
1228 Some(format!("{}:{}", ev.pubkey.to_hex(), d))
1229}
1230fn addr_coord(addr: &PackAddress) -> String {
1231 format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
1232}
1233
1234fn newest_events_by_coord(
1241 events: impl IntoIterator<Item = Event>,
1242 wanted: &std::collections::HashSet<String>,
1243) -> HashMap<String, Event> {
1244 let mut newest: HashMap<String, Event> = HashMap::new();
1245 for ev in events {
1246 if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
1247 let Some(coord) = event_coord(&ev) else { continue; };
1248 if !wanted.contains(&coord) { continue; }
1249 match newest.get(&coord) {
1250 Some(existing) if existing.created_at >= ev.created_at => {}
1251 _ => { newest.insert(coord, ev); }
1252 }
1253 }
1254 newest
1255}
1256
1257fn merge_newest(acc: &mut HashMap<String, Event>, phase: HashMap<String, Event>) {
1260 for (coord, ev) in phase {
1261 match acc.get(&coord) {
1262 Some(existing) if existing.created_at >= ev.created_at => {}
1263 _ => { acc.insert(coord, ev); }
1264 }
1265 }
1266}
1267
1268fn deletion_filter(addrs: &[&PackAddress]) -> Filter {
1271 Filter::new()
1272 .authors(addrs.iter().map(|a| a.pubkey))
1273 .kind(Kind::EventDeletion)
1274 .custom_tags(
1275 SingleLetterTag::lowercase(Alphabet::A),
1276 addrs.iter().map(|a| a.to_addr_string()),
1277 )
1278}
1279
1280fn deletion_matches(ev: &Event, author: &PublicKey, raw_addr: &str) -> bool {
1283 ev.kind == Kind::EventDeletion
1284 && ev.pubkey == *author
1285 && ev.tags.iter().any(|t| {
1286 let s = t.as_slice();
1287 s.len() >= 2 && s[0] == "a" && s[1] == raw_addr
1288 })
1289}
1290
1291async fn connected_read_relays(client: &Client, read_only: bool) -> std::collections::HashSet<String> {
1296 client
1297 .relays()
1298 .await
1299 .iter()
1300 .filter(|(_, r)| r.status() == RelayStatus::Connected && (!read_only || r.capabilities().load().can_read()))
1301 .map(|(url, _)| url.to_string())
1302 .collect()
1303}
1304
1305fn absence_is_clean(
1317 home_ok: bool,
1318 live_relays: usize,
1319 author_writes: Option<&Vec<RelayUrl>>,
1320 outbox_ok: bool,
1321 outbox_live: usize,
1322) -> bool {
1323 if !home_ok || live_relays < 2 {
1324 return false;
1325 }
1326 match author_writes {
1327 None => false,
1328 Some(w) if w.is_empty() => true,
1329 Some(_) => outbox_ok && outbox_live >= 1,
1330 }
1331}
1332
1333fn classify_pack(
1336 newest_ev: Option<&Event>,
1337 newest_deletion: Option<&Event>,
1338 me: Option<&str>,
1339 absence_clean: bool,
1340) -> (PackFetchOutcome, Option<EmojiPack>) {
1341 match newest_ev {
1342 Some(ev) => match parse_pack_from_event(ev, me) {
1343 Some(pack) => {
1344 match newest_deletion {
1347 Some(del) if del.created_at > ev.created_at => {
1348 (PackFetchOutcome::Tombstoned, None)
1349 }
1350 _ => (PackFetchOutcome::Found, Some(pack)),
1351 }
1352 }
1353 None => {
1354 let has_emoji_tags = ev.tags.iter().any(|t| {
1359 t.as_slice().first().map(|k| k == "emoji").unwrap_or(false)
1360 });
1361 if has_emoji_tags {
1362 (PackFetchOutcome::Unreachable, None)
1363 } else {
1364 (PackFetchOutcome::Tombstoned, None)
1365 }
1366 }
1367 },
1368 None if newest_deletion.is_some() => (PackFetchOutcome::Tombstoned, None),
1369 None if absence_clean => (PackFetchOutcome::CleanMiss, None),
1370 None => (PackFetchOutcome::Unreachable, None),
1371 }
1372}
1373
1374struct PackSweep {
1377 packs: Vec<EmojiPack>,
1378 outcomes: HashMap<String, PackFetchOutcome>,
1380}
1381
1382async fn sweep_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> PackSweep {
1388 if addrs.is_empty() {
1389 return PackSweep { packs: Vec::new(), outcomes: HashMap::new() };
1390 }
1391 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1392 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1393 let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1394 let all_refs: Vec<&PackAddress> = addrs.iter().collect();
1395
1396 let home_before = connected_read_relays(client, true).await;
1403 let home_filter = Filter::new()
1404 .authors(addrs.iter().map(|a| a.pubkey))
1405 .kind(Kind::Custom(KIND_EMOJI_SET))
1406 .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1407 let mut newest: HashMap<String, Event> = HashMap::new();
1408 let mut deletions: Vec<Event> = Vec::new();
1409 let mut home_ok = false;
1410 match client.fetch_events(home_filter).timeout(timeout).await {
1411 Ok(events) => {
1412 home_ok = true;
1413 merge_newest(&mut newest, newest_events_by_coord(events, &wanted));
1414 }
1415 Err(e) => {
1416 crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1417 }
1418 }
1419 if home_ok {
1420 match client.fetch_events(deletion_filter(&all_refs)).timeout(timeout).await {
1421 Ok(events) => deletions.extend(events),
1422 Err(e) => crate::log_warn!("[EmojiPacks] home deletion fetch failed: {}", e),
1423 }
1424 }
1425 let home_after = connected_read_relays(client, true).await;
1426 let mut live_relays: std::collections::HashSet<String> =
1427 home_before.intersection(&home_after).cloned().collect();
1428
1429 let misses: Vec<&PackAddress> = addrs.iter()
1433 .filter(|a| !newest.contains_key(&addr_coord(a)))
1434 .collect();
1435 let mut outbox_ok = false;
1436 let mut outbox_live = 0usize;
1437 if !misses.is_empty() {
1438 let miss_authors: Vec<PublicKey> = {
1439 let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1440 v.sort(); v.dedup();
1441 v
1442 };
1443 prefetch_author_write_relays(client, &miss_authors).await;
1446 let mut outbox: Vec<RelayUrl> = Vec::new();
1447 for pk in &miss_authors {
1448 for r in cached_write_relays(pk).unwrap_or_default() {
1449 if !outbox.contains(&r) { outbox.push(r); }
1450 }
1451 }
1452 if !outbox.is_empty() {
1453 let miss_filter = Filter::new()
1454 .authors(misses.iter().map(|a| a.pubkey))
1455 .kind(Kind::Custom(KIND_EMOJI_SET))
1456 .identifiers(misses.iter().map(|a| a.identifier.clone()));
1457 let wanted_misses: std::collections::HashSet<String> =
1458 misses.iter().map(|a| addr_coord(a)).collect();
1459 if let Some((events, dels, live)) = sweep_via_isolated_client(
1460 &outbox, miss_filter, deletion_filter(&misses), timeout,
1461 ).await {
1462 outbox_ok = true;
1463 outbox_live = live.len();
1464 live_relays.extend(live);
1465 merge_newest(&mut newest, newest_events_by_coord(events, &wanted_misses));
1466 deletions.extend(dels);
1467 }
1468 }
1469 }
1470
1471 let mut packs: Vec<EmojiPack> = Vec::new();
1473 let mut outcomes: HashMap<String, PackFetchOutcome> = HashMap::new();
1474 for addr in addrs {
1475 let coord = addr_coord(addr);
1476 let raw_addr = addr.to_addr_string();
1477 let newest_deletion = deletions.iter()
1478 .filter(|d| deletion_matches(d, &addr.pubkey, &raw_addr))
1479 .max_by_key(|d| d.created_at);
1480 let author_writes = cached_write_relays_verified(&addr.pubkey);
1481 let clean = absence_is_clean(
1482 home_ok,
1483 live_relays.len(),
1484 author_writes.as_ref(),
1485 outbox_ok,
1486 outbox_live,
1487 );
1488 let (outcome, pack) = classify_pack(
1489 newest.get(&coord),
1490 newest_deletion,
1491 me.as_deref(),
1492 clean,
1493 );
1494 if let Some(p) = pack {
1495 packs.push(p);
1496 }
1497 outcomes.insert(raw_addr, outcome);
1498 }
1499
1500 PackSweep { packs, outcomes }
1501}
1502
1503async fn sweep_via_isolated_client(
1510 relays: &[RelayUrl],
1511 pack_filter: Filter,
1512 del_filter: Filter,
1513 timeout: std::time::Duration,
1514) -> Option<(Vec<Event>, Vec<Event>, std::collections::HashSet<String>)> {
1515 let scratch = crate::nostr_client_builder()
1516 .build();
1517 for r in relays {
1518 let _ = scratch.add_managed_relay(r.as_str()).await;
1519 }
1520 scratch.connect().await;
1521 let connect_deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
1526 let before = loop {
1527 let connected = connected_read_relays(&scratch, false).await;
1528 if !connected.is_empty() || std::time::Instant::now() >= connect_deadline {
1529 break connected;
1530 }
1531 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1532 };
1533 let result = scratch.fetch_events(pack_filter).timeout(timeout).await;
1534 let dels = match &result {
1535 Ok(_) => scratch.fetch_events(del_filter).timeout(timeout).await.ok(),
1536 Err(_) => None,
1537 };
1538 let after = connected_read_relays(&scratch, false).await;
1539 scratch.shutdown().await;
1540
1541 match result {
1542 Ok(events) => Some((
1543 events.into_iter().collect(),
1544 dels.map(|d| d.into_iter().collect()).unwrap_or_default(),
1545 before.intersection(&after).cloned().collect(),
1546 )),
1547 Err(e) => {
1548 crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1549 None
1550 }
1551 }
1552}
1553
1554pub async fn fetch_subscribed_packs(
1563 client: &Client,
1564 my_pubkey: PublicKey,
1565 session: crate::state::SessionGuard,
1566) -> Result<Vec<EmojiPack>, String> {
1567 let list_filter = Filter::new()
1568 .author(my_pubkey)
1569 .kind(Kind::Custom(KIND_EMOJI_LIST))
1570 .limit(1);
1571
1572 let list_events = client
1573 .fetch_events(list_filter).timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1574 .await
1575 .map_err(|e| format!("fetch kind 10030: {}", e))?;
1576
1577 if !session.is_valid() {
1578 return Ok(Vec::new());
1579 }
1580
1581 let local_addrs = || -> Vec<PackAddress> {
1589 load_subscriptions()
1590 .unwrap_or_default()
1591 .into_iter()
1592 .filter_map(|s| parse_pack_address(&s).ok())
1593 .collect()
1594 };
1595 let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1596 .ok()
1597 .flatten()
1598 .and_then(|s| s.parse().ok())
1599 .unwrap_or(0);
1600
1601 let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1602 let mut fetched_anchor: Option<String> = None;
1605 let addrs: Vec<PackAddress> = match list_event {
1606 Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1607 crate::log_debug!(
1608 "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1609 ev.created_at.as_secs(), our_last_publish,
1610 );
1611 local_addrs()
1612 }
1613 Some(ev) => {
1614 let (a, anchor) = decrypt_subscribed_addresses_with_anchor(client, &my_pubkey, &ev).await;
1615 fetched_anchor = anchor;
1616 a
1617 }
1618 None => {
1619 crate::log_debug!(
1620 "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1621 );
1622 local_addrs()
1623 }
1624 };
1625
1626 let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1627
1628 let PackSweep { packs: fresh, outcomes } = sweep_packs_from_relays(client, &addrs).await;
1635 let tombstoned = outcomes.values().filter(|o| matches!(o, PackFetchOutcome::Tombstoned)).count();
1636 let unresolved = addrs.len().saturating_sub(fresh.len()).saturating_sub(tombstoned);
1637 if unresolved > 0 {
1638 crate::log_warn!(
1639 "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1640 unresolved,
1641 );
1642 }
1643 if tombstoned > 0 {
1644 crate::log_info!("[EmojiPacks] {} subscribed pack(s) deleted by their creator", tombstoned);
1645 }
1646
1647 if !session.is_valid() {
1648 return Ok(fresh);
1649 }
1650
1651 let me_hex = my_pubkey.to_hex();
1657 let now = std::time::SystemTime::now()
1658 .duration_since(std::time::UNIX_EPOCH)
1659 .map(|d| d.as_secs() as i64)
1660 .unwrap_or(0);
1661 let mut health_changed = false;
1662 for addr in &addrs {
1663 if addr.pubkey.to_hex() == me_hex { continue; }
1664 let raw = addr.to_addr_string();
1665 let Some(outcome) = outcomes.get(&raw) else { continue; };
1666 match apply_pack_health(&raw, outcome, now) {
1667 Ok(true) => {
1668 health_changed = true;
1669 crate::log_info!(
1670 "[EmojiPacks] pack `{}` health changed ({:?})",
1671 addr.identifier, outcome,
1672 );
1673 }
1674 Ok(false) => {}
1675 Err(e) => crate::log_warn!("[EmojiPacks] health update for `{}` failed: {}", addr.identifier, e),
1676 }
1677 }
1678 for pack in &fresh {
1679 if let Err(e) = save_pack(pack) {
1680 crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1681 }
1682 }
1683 let list_changed = load_subscriptions().unwrap_or_default() != addr_strings
1688 || fetched_anchor.as_deref().map_or(false, |a| a != load_theme_slot_anchor());
1689
1690 if let Err(e) = save_subscriptions(&addr_strings) {
1694 crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1695 }
1696 if let Some(anchor) = fetched_anchor {
1699 if let Err(e) = save_theme_slot_anchor(&anchor) {
1700 crate::log_warn!("[EmojiPacks] save theme slot anchor failed: {}", e);
1701 }
1702 }
1703 if health_changed || list_changed {
1704 crate::traits::emit_event("emoji_packs_updated", &());
1705 }
1706
1707 crate::log_info!(
1708 "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1709 fresh.len(),
1710 addrs.len(),
1711 if unresolved > 0 {
1712 format!(" ({} via cache)", unresolved)
1713 } else {
1714 String::new()
1715 },
1716 );
1717
1718 load_all_packs()
1721}
1722
1723pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1728 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1729 let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1730 let session = crate::state::SessionGuard::capture();
1731 fetch_subscribed_packs(&client, me, session).await
1732}
1733
1734pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1738 let addr = parse_naddr(naddr)?;
1739 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1740 fetch_pack_from_relays(&client, &addr).await
1741 .ok_or_else(|| {
1742 crate::log_debug!("[EmojiPacks] preview miss: {}:{}", addr.pubkey.to_hex(), addr.identifier);
1745 "Pack not found on any relay".to_string()
1746 })
1747}
1748
1749pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1757 let addr = parse_naddr(naddr)?;
1758 let coord = addr.to_addr_string();
1759
1760 if let Some(cached) = load_cached_pack(&coord)? {
1763 let naddr_owned = naddr.to_string();
1764 let session = crate::state::SessionGuard::capture();
1765 tokio::spawn(async move {
1766 if !session.is_valid() { return; }
1767 let Some(client) = nostr_client() else { return };
1768 if let Ok(parsed) = parse_naddr(&naddr_owned) {
1769 if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1770 if session.is_valid() && fresh.updated_at > cached.updated_at {
1771 if let Err(e) = save_pack(&fresh) {
1772 crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1773 } else {
1774 crate::traits::emit_event("emoji_packs_updated", &());
1775 }
1776 }
1777 }
1778 }
1779 });
1780 return Ok(Some(cached));
1781 }
1782
1783 let session = crate::state::SessionGuard::capture();
1785 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1786 match fetch_pack_from_relays(&client, &addr).await {
1787 Some(pack) => {
1788 if session.is_valid() {
1792 if let Err(e) = save_pack(&pack) {
1793 crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1794 }
1795 }
1796 Ok(Some(pack))
1797 }
1798 None => Ok(None),
1799 }
1800}
1801
1802const THEME_SLOT_ANCHOR_KEY: &str = "emoji_theme_slot_anchor";
1808
1809fn load_theme_slot_anchor() -> String {
1811 crate::db::settings::get_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string())
1812 .ok()
1813 .flatten()
1814 .unwrap_or_default()
1815}
1816
1817fn save_theme_slot_anchor(raw_addr: &str) -> Result<(), String> {
1819 crate::db::settings::set_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string(), raw_addr.to_string())
1820}
1821
1822fn build_inner_tags_with_marker(addrs: &[String], anchor: &str) -> Vec<Vec<String>> {
1827 let mut inner: Vec<Vec<String>> = Vec::with_capacity(addrs.len() + 1);
1828 let mut placed = false;
1829 if anchor.is_empty() {
1830 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1831 placed = true;
1832 }
1833 for addr in addrs {
1834 inner.push(vec!["a".to_string(), addr.clone()]);
1835 if !placed && addr == anchor {
1836 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1837 placed = true;
1838 }
1839 }
1840 if !placed {
1843 inner.insert(0, vec![THEME_SLOT_TOKEN.to_string()]);
1844 }
1845 inner
1846}
1847
1848pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1859 let addrs = load_subscriptions()?;
1860 let anchor = load_theme_slot_anchor();
1861 let my_pk = crate::state::my_public_key()
1862 .ok_or_else(|| "Not logged in".to_string())?;
1863
1864 let inner_tags = build_inner_tags_with_marker(&addrs, &anchor);
1865 let plaintext = serde_json::to_string(&inner_tags)
1866 .map_err(|e| format!("Serialise emoji list: {}", e))?;
1867
1868 let signer = crate::signer::active_signer()
1869 .map_err(|e| format!("Signer unavailable: {}", e))?;
1870 let content = signer.nip44_encrypt_async(&my_pk, &plaintext).await
1871 .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1872
1873 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1874 crate::sign_and_send(&client, builder).await
1875 .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1876
1877 crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1878 Ok(())
1879}
1880
1881const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1885
1886static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1887
1888pub fn republish_emoji_list_debounced() {
1892 use std::sync::atomic::Ordering;
1893 let _ = crate::db::settings::set_sql_setting(
1899 EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1900 Timestamp::now().as_secs().to_string(),
1901 );
1902 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1903 let session = crate::state::SessionGuard::capture();
1904 tokio::spawn(async move {
1905 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1906 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1907 if !session.is_valid() { return; }
1908 let client = match nostr_client() {
1909 Some(c) => c,
1910 None => return,
1911 };
1912 if let Err(e) = publish_emoji_list(&client).await {
1913 crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1914 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1915 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1916 if !session.is_valid() { return; }
1917 if let Err(e2) = publish_emoji_list(&client).await {
1918 crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1919 }
1920 }
1921 });
1922}
1923
1924pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1928 let session = crate::state::SessionGuard::capture();
1929 let pack = fetch_pack_by_naddr(naddr).await?;
1930 if !session.is_valid() {
1931 return Err("Account swapped during fetch — aborted".to_string());
1932 }
1933
1934 let pack_addr = pack.addr();
1937 {
1938 let existing_subs = load_subscriptions()?;
1939 let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
1940 let cap = effective_max_equipped_packs();
1941 if is_new && existing_subs.len() >= cap {
1942 return Err(format!(
1943 "You can equip at most {} packs. Remove one to add another.",
1944 cap,
1945 ));
1946 }
1947 }
1948
1949 save_pack(&pack)?;
1950
1951 let mut subs = load_subscriptions()?;
1952 if !subs.iter().any(|a| a == &pack_addr) {
1953 subs.push(pack_addr.clone());
1954 }
1955 if !session.is_valid() {
1956 return Err("Account swapped before subscription save — aborted".to_string());
1957 }
1958 save_subscriptions(&subs)?;
1959
1960 republish_emoji_list_debounced();
1961 crate::traits::emit_event("emoji_packs_updated", &());
1962
1963 Ok(pack)
1964}
1965
1966fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1975 if pack.identifier.is_empty() {
1976 return Err("pack identifier required".to_string());
1977 }
1978 let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1979 .tag(Tag::custom("d", [pack.identifier.clone()]))
1980 .tag(Tag::custom("client", ["vector"]));
1983
1984 if !pack.title.is_empty() {
1986 builder = builder
1987 .tag(Tag::custom("title", [pack.title.clone()]))
1988 .tag(Tag::custom("name", [pack.title.clone()]));
1989 }
1990 if !pack.image_url.is_empty() {
1991 builder = builder
1992 .tag(Tag::custom("image", [pack.image_url.clone()]))
1993 .tag(Tag::custom("picture", [pack.image_url.clone()]));
1994 }
1995 if !pack.description.is_empty() {
1996 builder = builder
1997 .tag(Tag::custom("description", [pack.description.clone()]))
1998 .tag(Tag::custom("about", [pack.description.clone()]));
1999 }
2000
2001 for e in &pack.emojis {
2002 if e.shortcode.is_empty() || e.url.is_empty() { continue; }
2003 builder = builder.tag(Tag::custom(
2004 "emoji",
2005 [e.shortcode.clone(), e.url.clone()],
2006 ));
2007 }
2008 Ok(builder)
2009}
2010
2011pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
2016 let session = crate::state::SessionGuard::capture();
2017 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2018 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2019
2020 let emoji_cap = effective_max_emojis_per_pack();
2023 if pack.emojis.len() > emoji_cap {
2024 return Err(format!(
2025 "A pack can hold at most {} emojis.",
2026 emoji_cap,
2027 ));
2028 }
2029
2030 let mut to_save = pack.clone();
2033 to_save.pubkey = my_pk.to_hex();
2034 to_save.is_own = true;
2035 let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
2036 to_save.id = naddr_from_addr(&raw_addr)?;
2037 to_save.updated_at = std::time::SystemTime::now()
2038 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
2039
2040 {
2043 let existing_subs = load_subscriptions()?;
2044 let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
2045 let cap = effective_max_equipped_packs();
2046 if is_new && existing_subs.len() >= cap {
2047 return Err(format!(
2048 "You can equip at most {} packs. Remove one to add another.",
2049 cap,
2050 ));
2051 }
2052 }
2053
2054 let builder = build_pack_event(&to_save)?;
2055 crate::sign_and_send(&client, builder).await
2056 .map_err(|e| format!("publish kind 30030: {}", e))?;
2057
2058 if !session.is_valid() {
2059 return Err("Account swapped during publish — local state untouched".to_string());
2060 }
2061
2062 save_pack(&to_save)?;
2063
2064 let mut subs = load_subscriptions()?;
2067 if !subs.iter().any(|a| a == &raw_addr) {
2068 subs.push(raw_addr.clone());
2069 if !session.is_valid() {
2070 return Err("Account swapped before subscription save — aborted".to_string());
2071 }
2072 save_subscriptions(&subs)?;
2073 republish_emoji_list_debounced();
2074 }
2075
2076 crate::traits::emit_event("emoji_packs_updated", &());
2077 crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
2078 to_save.identifier, to_save.emojis.len());
2079
2080 Ok(to_save)
2081}
2082
2083pub async fn delete_own_pack(id: &str) -> Result<(), String> {
2087 let session = crate::state::SessionGuard::capture();
2088 let parsed = parse_naddr(id)?;
2089 let raw_addr = parsed.to_addr_string();
2090 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2091 if parsed.pubkey != my_pk {
2092 return Err("Cannot delete a pack you don't own".to_string());
2093 }
2094 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2095
2096 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2097 .tag(Tag::custom("d", [parsed.identifier.clone()]));
2098 crate::sign_and_send(&client, builder).await
2099 .map_err(|e| format!("publish empty kind 30030: {}", e))?;
2100
2101 if !session.is_valid() {
2102 return Err("Account swapped during delete — local state untouched".to_string());
2103 }
2104
2105 {
2109 let mut conn = crate::db::get_write_connection_guard_static()?;
2110 let tx = conn.transaction()
2111 .map_err(|e| format!("begin delete tx: {}", e))?;
2112 tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
2113 rusqlite::params![raw_addr])
2114 .map_err(|e| format!("drop subscription: {}", e))?;
2115 tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
2116 rusqlite::params![raw_addr])
2117 .map_err(|e| format!("drop pack row: {}", e))?;
2118 tx.commit()
2119 .map_err(|e| format!("commit delete tx: {}", e))?;
2120 }
2121
2122 republish_emoji_list_debounced();
2123 crate::traits::emit_event("emoji_packs_updated", &());
2124 crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
2125 Ok(())
2126}
2127
2128pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
2132 let session = crate::state::SessionGuard::capture();
2133 let raw_addr = parse_naddr(id)?.to_addr_string();
2134 let mut subs = load_subscriptions()?;
2135 let before = subs.len();
2136 subs.retain(|a| a != &raw_addr);
2137 if subs.len() == before {
2138 return Ok(()); }
2140 if !session.is_valid() {
2141 return Err("Account swapped before unsubscribe save — aborted".to_string());
2142 }
2143 save_subscriptions(&subs)?;
2144 republish_emoji_list_debounced();
2145 crate::traits::emit_event("emoji_packs_updated", &());
2146 Ok(())
2147}
2148
2149pub fn reorder_emoji_packs(ordered_ids: Vec<String>) -> Result<(), String> {
2158 let session = crate::state::SessionGuard::capture();
2159
2160 let mut real_addrs: Vec<String> = Vec::with_capacity(ordered_ids.len());
2161 let mut anchor = String::new();
2162 let mut marker_present = false;
2163 for id in &ordered_ids {
2164 if id == THEME_SLOT_TOKEN {
2165 marker_present = true;
2166 anchor = real_addrs.last().cloned().unwrap_or_default();
2167 } else {
2168 real_addrs.push(parse_naddr(id)?.to_addr_string());
2169 }
2170 }
2171
2172 if !session.is_valid() {
2173 return Err("Account swapped during reorder — aborted".to_string());
2174 }
2175 save_subscriptions(&real_addrs)?;
2176 if marker_present {
2180 save_theme_slot_anchor(&anchor)?;
2181 }
2182 republish_emoji_list_debounced();
2183 crate::traits::emit_event("emoji_packs_updated", &());
2184 Ok(())
2185}
2186
2187pub fn get_theme_slot_anchor() -> Result<String, String> {
2191 let raw = load_theme_slot_anchor();
2192 if raw.is_empty() {
2193 return Ok(String::new());
2194 }
2195 Ok(naddr_from_addr(&raw).unwrap_or_default())
2196}
2197
2198#[cfg(test)]
2203mod tests {
2204 use super::*;
2205
2206 fn keys() -> Keys {
2207 Keys::generate()
2208 }
2209
2210 fn build_pack_event(
2211 k: &Keys,
2212 d: &str,
2213 title_tag: Option<(&str, &str)>,
2214 image_tag: Option<(&str, &str)>,
2215 desc_tag: Option<(&str, &str)>,
2216 emojis: &[(&str, &str)],
2217 ) -> Event {
2218 let mut tags: Vec<Tag> = Vec::new();
2219 tags.push(Tag::custom("d", [d]));
2220 if let Some((key, val)) = title_tag {
2221 tags.push(Tag::custom(key, [val]));
2222 }
2223 if let Some((key, val)) = image_tag {
2224 tags.push(Tag::custom(key, [val]));
2225 }
2226 if let Some((key, val)) = desc_tag {
2227 tags.push(Tag::custom(key, [val]));
2228 }
2229 for (code, url) in emojis {
2230 tags.push(Tag::custom("emoji", [*code, *url]));
2231 }
2232 EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2233 .tags(tags)
2234 .finalize(k)
2235 .unwrap()
2236 }
2237
2238 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2239 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2240 crate::db::close_database();
2241 crate::db::clear_id_caches();
2244 let tmp = tempfile::tempdir().unwrap();
2245 let account = keys().public_key().to_bech32().unwrap();
2246 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2247 crate::db::set_app_data_dir(tmp.path().to_path_buf());
2248 crate::db::set_current_account(account.clone()).unwrap();
2249 crate::db::init_database(&account).unwrap();
2250 (tmp, guard)
2251 }
2252
2253 fn seed_pack(k: &Keys, d: &str, emojis: &[(&str, &str)]) -> String {
2255 let ev = build_pack_event(k, d, None, None, None, emojis);
2256 let pack = parse_pack_from_event(&ev, None).unwrap();
2257 save_pack(&pack).unwrap();
2258 pack.addr()
2259 }
2260
2261 fn seed_usage(url: &str) {
2262 let conn = crate::db::get_write_connection_guard_static().unwrap();
2263 conn.execute(
2264 "INSERT INTO emoji_usage (kind, id, url, score, last_used) VALUES (1, ?1, ?1, 1.0, 0)",
2265 rusqlite::params![url],
2266 ).unwrap();
2267 }
2268
2269 fn usage_count() -> i64 {
2270 let conn = crate::db::get_db_connection_guard_static().unwrap();
2271 conn.query_row("SELECT COUNT(*) FROM emoji_usage", [], |r| r.get(0)).unwrap()
2272 }
2273
2274 fn pack_status(addr: &str) -> u8 {
2275 load_cached_pack(addr).unwrap().unwrap().status
2276 }
2277
2278 fn build_deletion_event(k: &Keys, addr: &str) -> Event {
2279 EventBuilder::new(Kind::EventDeletion, "")
2280 .tag(Tag::custom("a", [addr]))
2281 .finalize(k)
2282 .unwrap()
2283 }
2284
2285 #[test]
2286 fn classify_verdict_table() {
2287 let k = keys();
2288 let live = build_pack_event(&k, "p", None, None, None, &[("a", "https://e.x/a.png")]);
2289 let empty = build_pack_event(&k, "p", None, None, None, &[]);
2290 let addr = format!("30030:{}:p", k.public_key().to_hex());
2291 let del = build_deletion_event(&k, &addr);
2292
2293 let (o, p) = classify_pack(Some(&live), None, None, false);
2295 assert_eq!(o, PackFetchOutcome::Found);
2296 assert!(p.is_some());
2297
2298 let (o, _) = classify_pack(Some(&empty), None, None, true);
2300 assert_eq!(o, PackFetchOutcome::Tombstoned);
2301
2302 let (o, _) = classify_pack(None, Some(&del), None, true);
2304 assert_eq!(o, PackFetchOutcome::Tombstoned);
2305
2306 let ts = Timestamp::from_secs(1_700_000_000);
2309 let live_pinned = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2310 .tags(vec![
2311 Tag::custom("d", ["p"]),
2312 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2313 ])
2314 .custom_created_at(ts)
2315 .finalize(&k)
2316 .unwrap();
2317 let del_at = |secs: u64| {
2318 EventBuilder::new(Kind::EventDeletion, "")
2319 .tag(Tag::custom("a", [addr.as_str()]))
2320 .custom_created_at(Timestamp::from_secs(secs))
2321 .finalize(&k)
2322 .unwrap()
2323 };
2324
2325 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_000)), None, false);
2328 assert_eq!(o, PackFetchOutcome::Found);
2329 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_001)), None, false);
2331 assert_eq!(o, PackFetchOutcome::Tombstoned);
2332 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_699_999_999)), None, false);
2334 assert_eq!(o, PackFetchOutcome::Found);
2335
2336 let (o, _) = classify_pack(None, None, None, true);
2338 assert_eq!(o, PackFetchOutcome::CleanMiss);
2339 let (o, _) = classify_pack(None, None, None, false);
2340 assert_eq!(o, PackFetchOutcome::Unreachable);
2341 }
2342
2343 #[test]
2344 fn classify_invalid_emojis_is_not_a_tombstone() {
2345 let k = keys();
2348 let ev = build_pack_event(&k, "weird", None, None, None, &[("bad!code", "https://e.x/a.png")]);
2349 assert!(parse_pack_from_event(&ev, None).is_none(), "precondition: unparseable");
2350 let (o, _) = classify_pack(Some(&ev), None, None, true);
2351 assert_eq!(o, PackFetchOutcome::Unreachable);
2352 }
2353
2354 #[test]
2355 fn absence_gate_table() {
2356 let some_writes = vec![RelayUrl::parse("wss://relay.author.example").unwrap()];
2357 let no_writes: Vec<RelayUrl> = Vec::new();
2358
2359 assert!(!absence_is_clean(false, 5, Some(&no_writes), true, 1));
2361 assert!(!absence_is_clean(true, 1, Some(&no_writes), true, 1));
2362 assert!(!absence_is_clean(true, 3, None, true, 1));
2365 assert!(absence_is_clean(true, 2, Some(&no_writes), false, 0));
2367 assert!(!absence_is_clean(true, 3, Some(&some_writes), false, 0));
2369 assert!(!absence_is_clean(true, 3, Some(&some_writes), true, 0));
2370 assert!(absence_is_clean(true, 3, Some(&some_writes), true, 1));
2371 }
2372
2373 #[test]
2374 fn deletion_matching_is_author_bound() {
2375 let author = keys();
2376 let stranger = keys();
2377 let addr = format!("30030:{}:p", author.public_key().to_hex());
2378 let forged = build_deletion_event(&stranger, &addr);
2380 assert!(!deletion_matches(&forged, &author.public_key(), &addr));
2381 let real = build_deletion_event(&author, &addr);
2382 assert!(deletion_matches(&real, &author.public_key(), &addr));
2383 }
2384
2385 #[test]
2386 fn tombstone_revokes_immediately_and_purges_frecency() {
2387 let (_tmp, _guard) = init_test_db();
2388 let k = keys();
2389 let addr = seed_pack(&k, "deadpack", &[("boom", "https://e.x/boom.png")]);
2390 seed_usage("https://e.x/boom.png");
2391 seed_usage("https://e.x/unrelated.png");
2392
2393 let changed = apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2394 assert!(changed, "tombstone must flip status in one sweep");
2395 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2396 assert_eq!(usage_count(), 1, "only the dead pack's usage rows purge");
2397 }
2398
2399 #[test]
2400 fn found_self_heals_from_revoked() {
2401 let (_tmp, _guard) = init_test_db();
2402 let k = keys();
2403 let addr = seed_pack(&k, "phoenix", &[("rise", "https://e.x/r.png")]);
2404 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2405 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2406
2407 let changed = apply_pack_health(&addr, &PackFetchOutcome::Found, 2_000).unwrap();
2408 assert!(changed);
2409 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "a republished pack revives");
2410 }
2411
2412 #[test]
2413 fn clean_miss_gauntlet_rate_limit_and_promotion() {
2414 let (_tmp, _guard) = init_test_db();
2415 let k = keys();
2416 let addr = seed_pack(&k, "fading", &[("bye", "https://e.x/bye.png")]);
2417 let t0: i64 = 1_000_000;
2418 let h = 3_600;
2419
2420 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0).unwrap());
2422 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + h).unwrap());
2423 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 13 * h).unwrap());
2425 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 26 * h).unwrap());
2426 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "count alone must not promote");
2427 let changed = apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 49 * h).unwrap();
2429 assert!(changed);
2430 assert_eq!(pack_status(&addr), PACK_STATUS_MISSING);
2431 }
2432
2433 #[test]
2434 fn unreachable_never_moves_counters() {
2435 let (_tmp, _guard) = init_test_db();
2436 let k = keys();
2437 let addr = seed_pack(&k, "offline", &[("zz", "https://e.x/z.png")]);
2438 for t in [1_000i64, 200_000, 400_000, 800_000] {
2439 assert!(!apply_pack_health(&addr, &PackFetchOutcome::Unreachable, t).unwrap());
2440 }
2441 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE);
2442 let conn = crate::db::get_db_connection_guard_static().unwrap();
2443 let miss: i64 = conn.query_row(
2444 "SELECT miss_count FROM emoji_packs WHERE addr = ?1",
2445 rusqlite::params![addr], |r| r.get(0),
2446 ).unwrap();
2447 assert_eq!(miss, 0, "offline sweeps must not accrue misses");
2448 }
2449
2450 #[test]
2451 fn revoked_ignores_clean_misses() {
2452 let (_tmp, _guard) = init_test_db();
2453 let k = keys();
2454 let addr = seed_pack(&k, "sealed", &[("x", "https://e.x/x.png")]);
2455 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2456 for t in [100_000i64, 300_000, 600_000] {
2457 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t).unwrap());
2458 }
2459 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED, "misses can't downgrade a tombstone verdict");
2460 }
2461
2462 #[test]
2463 fn empty_pack_event_is_the_tombstone_shape() {
2464 let k = keys();
2468 let ev = build_pack_event(&k, "gone", None, None, None, &[]);
2469 assert!(parse_pack_from_event(&ev, None).is_none());
2470 }
2471
2472 #[test]
2473 fn parse_pack_reads_nip51_spec_tags() {
2474 let k = keys();
2475 let ev = build_pack_event(
2476 &k, "myPack",
2477 Some(("title", "Spec Pack")),
2478 Some(("image", "https://example.com/p.png")),
2479 Some(("description", "specd")),
2480 &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
2481 );
2482 let pack = parse_pack_from_event(&ev, None).unwrap();
2483 assert_eq!(pack.identifier, "myPack");
2484 assert_eq!(pack.title, "Spec Pack");
2485 assert_eq!(pack.image_url, "https://example.com/p.png");
2486 assert_eq!(pack.description, "specd");
2487 assert_eq!(pack.emojis.len(), 2);
2488 assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
2489 }
2490
2491 #[test]
2492 fn own_pack_event_carries_the_vector_client_tag() {
2493 let k = keys();
2496 let ev = build_pack_event(&k, "myPack", Some(("title", "Mine")), None, None, &[("smile", "https://e.x/s.png")]);
2497 let pack = parse_pack_from_event(&ev, None).unwrap();
2498 let built = super::build_pack_event(&pack).unwrap().finalize(&k).unwrap();
2499 let has_client = built.tags.iter().any(|t| {
2500 let s = t.as_slice();
2501 s.first().map(String::as_str) == Some("client") && s.get(1).map(String::as_str) == Some("vector")
2502 });
2503 assert!(has_client, "own pack event must carry the [\"client\",\"vector\"] tag");
2504 }
2505
2506 #[test]
2507 fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
2508 let k = keys();
2509 let ev = build_pack_event(
2510 &k, "ditto",
2511 Some(("name", "Ditto Pack")),
2512 Some(("picture", "https://example.com/d.png")),
2513 Some(("about", "ditto-style")),
2514 &[("yes", "https://e.x/y.png")],
2515 );
2516 let pack = parse_pack_from_event(&ev, None).unwrap();
2517 assert_eq!(pack.title, "Ditto Pack");
2518 assert_eq!(pack.image_url, "https://example.com/d.png");
2519 assert_eq!(pack.description, "ditto-style");
2520 }
2521
2522 #[test]
2523 fn parse_pack_prefers_spec_tags_over_ditto() {
2524 let k = keys();
2525 let mut tags: Vec<Tag> = vec![
2526 Tag::custom("d", ["both"]),
2527 Tag::custom("title", ["SpecTitle"]),
2528 Tag::custom("name", ["DittoName"]),
2529 Tag::custom("image", ["spec.png"]),
2530 Tag::custom("picture", ["ditto.png"]),
2531 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2532 ];
2533 tags.extend(std::iter::empty());
2534 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2535 .tags(tags).finalize(&k).unwrap();
2536 let pack = parse_pack_from_event(&ev, None).unwrap();
2537 assert_eq!(pack.title, "SpecTitle");
2538 assert_eq!(pack.image_url, "spec.png");
2539 }
2540
2541 #[test]
2542 fn parse_pack_returns_none_without_d_tag() {
2543 let k = keys();
2544 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2545 .tags(vec![
2546 Tag::custom("title", ["No D"]),
2547 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2548 ])
2549 .finalize(&k).unwrap();
2550 assert!(parse_pack_from_event(&ev, None).is_none());
2551 }
2552
2553 #[test]
2554 fn parse_pack_returns_none_when_no_valid_emojis() {
2555 let k = keys();
2556 let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
2557 assert!(parse_pack_from_event(&ev, None).is_none());
2558 }
2559
2560 #[test]
2561 fn parse_pack_rejects_invalid_shortcodes() {
2562 let k = keys();
2563 let ev = build_pack_event(
2564 &k, "mix", Some(("title", "Mix")), None, None,
2565 &[("ok_name", "https://e.x/a.png"),
2566 ("bad name", "https://e.x/b.png"),
2567 ("colons:no", "https://e.x/c.png"),
2568 ("", "https://e.x/d.png"),
2569 ("dash-ok", "https://e.x/e.png")],
2570 );
2571 let pack = parse_pack_from_event(&ev, None).unwrap();
2572 let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
2573 assert_eq!(codes, vec!["ok_name", "dash-ok"]);
2574 }
2575
2576 #[test]
2577 fn parse_pack_dedupes_shortcodes_first_wins() {
2578 let k = keys();
2579 let ev = build_pack_event(
2580 &k, "dup", Some(("title", "Dup")), None, None,
2581 &[("smile", "https://e.x/first.png"),
2582 ("smile", "https://e.x/second.png")],
2583 );
2584 let pack = parse_pack_from_event(&ev, None).unwrap();
2585 assert_eq!(pack.emojis.len(), 1);
2586 assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
2587 }
2588
2589 #[test]
2590 fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
2591 let k = keys();
2592 let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
2593 &[("a", "https://e.x/a.png")]);
2594 let my_hex = k.public_key().to_hex();
2595 let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
2596 assert!(pack_mine.is_own);
2597
2598 let stranger = keys().public_key().to_hex();
2599 let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
2600 assert!(!pack_other.is_own);
2601 }
2602
2603 #[test]
2604 fn pack_address_to_string_round_trips() {
2605 let k = keys();
2606 let hex = k.public_key().to_hex();
2607 let addr = PackAddress {
2608 kind: 30030,
2609 pubkey: k.public_key(),
2610 identifier: "myPack".to_string(),
2611 };
2612 assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
2613 let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
2614 assert_eq!(parsed, addr);
2615 }
2616
2617 #[test]
2618 fn parse_naddr_round_trips_kind_30030_coordinate() {
2619 let k = keys();
2622 let coord = nostr_sdk::prelude::nip01::Coordinate {
2623 kind: Kind::Custom(30030),
2624 public_key: k.public_key(),
2625 identifier: "trip".to_string(),
2626 };
2627 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2628 coordinate: coord,
2629 relays: Vec::new(),
2630 };
2631 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2632 let parsed = parse_naddr(&naddr).unwrap();
2633 assert_eq!(parsed.kind, 30030);
2634 assert_eq!(parsed.pubkey, k.public_key());
2635 assert_eq!(parsed.identifier, "trip");
2636 }
2637
2638 #[test]
2639 fn parse_naddr_rejects_non_30030_kinds() {
2640 let k = keys();
2641 let coord = nostr_sdk::prelude::nip01::Coordinate {
2642 kind: Kind::Custom(30023), public_key: k.public_key(),
2644 identifier: "essay".to_string(),
2645 };
2646 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2647 coordinate: coord,
2648 relays: Vec::new(),
2649 };
2650 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2651 let err = parse_naddr(&naddr).unwrap_err();
2652 assert!(err.contains("expected kind 30030"),
2653 "expected kind-rejection error, got: {}", err);
2654 }
2655
2656 #[test]
2657 fn parse_naddr_strips_nostr_uri_prefix() {
2658 let k = keys();
2659 let coord = nostr_sdk::prelude::nip01::Coordinate {
2660 kind: Kind::Custom(30030),
2661 public_key: k.public_key(),
2662 identifier: "prefixed".to_string(),
2663 };
2664 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2665 coordinate: coord,
2666 relays: Vec::new(),
2667 };
2668 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2669 let with_prefix = format!("nostr:{}", naddr);
2670 let parsed = parse_naddr(&with_prefix).unwrap();
2671 assert_eq!(parsed.identifier, "prefixed");
2672 }
2673
2674 #[test]
2675 fn parse_naddr_rejects_garbage_input() {
2676 assert!(parse_naddr("not an naddr").is_err());
2677 assert!(parse_naddr("naddr1invalid").is_err());
2678 assert!(parse_naddr("").is_err());
2679 }
2680
2681 #[test]
2682 fn parse_pack_address_round_trips_valid_input() {
2683 let k = keys();
2684 let hex = k.public_key().to_hex();
2685 let addr = format!("30030:{}:myId", hex);
2686 let parsed = parse_pack_address(&addr).unwrap();
2687 assert_eq!(parsed.kind, 30030);
2688 assert_eq!(parsed.pubkey, k.public_key());
2689 assert_eq!(parsed.identifier, "myId");
2690 }
2691
2692 #[test]
2693 fn parse_pack_address_rejects_wrong_kind() {
2694 let hex = keys().public_key().to_hex();
2695 let addr = format!("10030:{}:x", hex);
2696 assert!(parse_pack_address(&addr).is_err());
2697 }
2698
2699 #[test]
2700 fn parse_pack_address_rejects_malformed_pubkey() {
2701 let addr = "30030:not-hex:x".to_string();
2702 assert!(parse_pack_address(&addr).is_err());
2703 }
2704
2705 #[test]
2706 fn parse_pack_address_preserves_colons_in_identifier() {
2707 let hex = keys().public_key().to_hex();
2709 let addr = format!("30030:{}:id:with:colons", hex);
2710 let parsed = parse_pack_address(&addr).unwrap();
2711 assert_eq!(parsed.identifier, "id:with:colons");
2712 }
2713
2714 #[test]
2715 fn parse_inner_tag_list_extracts_valid_a_tags() {
2716 let hex_a = keys().public_key().to_hex();
2720 let hex_b = keys().public_key().to_hex();
2721 let plaintext = format!(
2722 r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
2723 a = hex_a,
2724 b = hex_b,
2725 );
2726 let addrs = parse_inner_tag_list(&plaintext);
2727 assert_eq!(addrs.len(), 2);
2728 assert_eq!(addrs[0].identifier, "packA");
2729 assert_eq!(addrs[1].identifier, "packB");
2730 }
2731
2732 #[test]
2733 fn parse_inner_tag_list_returns_empty_on_malformed_json() {
2734 assert!(parse_inner_tag_list("not json").is_empty());
2735 assert!(parse_inner_tag_list("").is_empty());
2736 }
2737
2738 #[test]
2739 fn theme_slot_marker_round_trips_through_publish_and_parse() {
2740 let a = keys().public_key().to_hex();
2743 let b = keys().public_key().to_hex();
2744 let c = keys().public_key().to_hex();
2745 let addr_a = format!("30030:{}:packA", a);
2746 let addr_b = format!("30030:{}:packB", b);
2747 let addr_c = format!("30030:{}:packC", c);
2748 let addrs = vec![addr_a.clone(), addr_b.clone(), addr_c.clone()];
2749
2750 let inner = build_inner_tags_with_marker(&addrs, &addr_b);
2751 let marker_count = inner.iter()
2752 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2753 .count();
2754 assert_eq!(marker_count, 1, "exactly one marker is emitted");
2755
2756 let plaintext = serde_json::to_string(&inner).unwrap();
2757 let (parsed, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
2758 assert_eq!(parsed, addrs, "addr order survives the round trip");
2759 assert_eq!(anchor.as_deref(), Some(addr_b.as_str()), "anchor is the 2nd pack");
2760
2761 let old = format!(r#"[["a","{}"],["a","{}"]]"#, addr_a, addr_b);
2763 let (old_addrs, old_anchor) = parse_inner_tag_list_with_anchor(&old);
2764 assert_eq!(old_addrs, vec![addr_a.clone(), addr_b.clone()]);
2765 assert_eq!(old_anchor, None, "a list without the marker has no anchor");
2766
2767 let top = build_inner_tags_with_marker(&addrs, "");
2769 assert_eq!(top[0], vec![THEME_SLOT_TOKEN.to_string()]);
2770 let (_, top_anchor) =
2771 parse_inner_tag_list_with_anchor(&serde_json::to_string(&top).unwrap());
2772 assert_eq!(top_anchor.as_deref(), Some(""), "top slot = empty anchor");
2773
2774 let gone = format!("30030:{}:ghost", keys().public_key().to_hex());
2776 let fallback = build_inner_tags_with_marker(&addrs, &gone);
2777 assert_eq!(fallback[0], vec![THEME_SLOT_TOKEN.to_string()]);
2778 let fb_markers = fallback.iter()
2779 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2780 .count();
2781 assert_eq!(fb_markers, 1, "still exactly one marker on the fallback path");
2782 }
2783
2784 #[test]
2785 fn shortcode_validator_accepts_alphanum_dash_underscore() {
2786 assert!(is_valid_shortcode("smile"));
2787 assert!(is_valid_shortcode("smile_face"));
2788 assert!(is_valid_shortcode("smile-face"));
2789 assert!(is_valid_shortcode("Smile2"));
2790 assert!(!is_valid_shortcode(""));
2791 assert!(!is_valid_shortcode("smile face"));
2792 assert!(!is_valid_shortcode("smile:face"));
2793 assert!(!is_valid_shortcode("😀"));
2794 assert!(!is_valid_shortcode("love~2"));
2797 }
2798
2799 #[test]
2800 fn resolve_token_disambiguates_duplicate_shortcodes() {
2801 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
2803 by_code.insert(
2804 "love".to_string(),
2805 vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
2806 );
2807 by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
2808
2809 assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
2811 assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
2813 assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
2814 assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
2816 assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
2817 assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
2818 assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
2820 assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
2821 }
2822
2823 #[test]
2824 fn message_emoji_tags_accept_disambiguation_separator() {
2825 let tags = vec![
2827 vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
2828 vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
2829 ];
2830 let out = crate::types::EmojiTag::extract_from_stored(&tags);
2831 assert_eq!(out.len(), 1);
2832 assert_eq!(out[0].shortcode, "love~2");
2833 }
2834}