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 known_shortcodes() -> std::collections::HashSet<String> {
200 let mut out: std::collections::HashSet<String> = std::collections::HashSet::new();
201 if let Ok(conn) = crate::db::get_db_connection_guard_static() {
202 if let Ok(mut stmt) = conn.prepare("SELECT DISTINCT shortcode FROM emoji_pack_items") {
203 if let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) {
204 out.extend(rows.flatten());
205 }
206 }
207 }
208 if let Ok(theme) = theme_emoji_tags().lock() {
209 out.extend(theme.iter().map(|(code, _)| code.clone()));
210 }
211 out
212}
213
214pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
217 if let Ok(mut g) = theme_emoji_tags().lock() {
218 *g = tags;
219 }
220}
221
222pub fn effective_display_cap() -> usize {
228 EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
229}
230
231fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
236 if let Some((base, suffix)) = token.rsplit_once('~') {
237 let n: usize = suffix.parse().ok()?;
238 if n == 0 { return None; }
239 return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
240 }
241 by_code.get(token).and_then(|urls| urls.first().cloned())
242}
243
244pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
253 if content.is_empty() || !content.contains(':') {
254 return Vec::new();
255 }
256
257 let cap = effective_display_cap();
259 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
260
261 if let Ok(conn) = crate::db::get_db_connection_guard_static() {
267 if let Ok(mut stmt) = conn.prepare(
268 "SELECT p.addr, i.shortcode, i.url
269 FROM emoji_pack_items i
270 INNER JOIN emoji_packs p ON p.addr = i.pack_addr
271 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
272 WHERE p.status = 0
273 ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
274 ) {
275 if let Ok(rows) = stmt.query_map([], |row| {
276 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
277 }) {
278 let mut per_pack: HashMap<String, usize> = HashMap::new();
281 for (addr, code, url) in rows.flatten() {
282 let n = per_pack.entry(addr).or_insert(0);
283 if *n >= cap { continue; }
284 *n += 1;
285 by_code.entry(code).or_default().push(url);
286 }
287 }
288 }
289 }
290
291 if let Ok(theme) = theme_emoji_tags().lock() {
295 for (code, url) in theme.iter() {
296 by_code.entry(code.clone()).or_default().push(url.clone());
297 }
298 }
299
300 if by_code.is_empty() {
301 return Vec::new();
302 }
303
304 for urls in by_code.values_mut() {
307 urls.sort();
308 urls.dedup();
309 }
310
311 let mut out: Vec<crate::types::EmojiTag> = Vec::new();
312 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
313 let bytes = content.as_bytes();
314 let mut i = 0;
315 while i < bytes.len() {
316 if bytes[i] == b':' {
317 let start = i + 1;
318 let mut j = start;
319 while j < bytes.len() {
320 let c = bytes[j];
321 let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
323 if !ok { break; }
324 j += 1;
325 }
326 if j > start && j < bytes.len() && bytes[j] == b':' {
327 if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
328 if !seen.contains(token) {
329 if let Some(url) = resolve_emoji_token(&by_code, token) {
330 out.push(crate::types::EmojiTag {
331 shortcode: token.to_string(),
332 url,
333 });
334 seen.insert(token.to_string());
335 }
336 }
337 }
338 i = j + 1;
339 continue;
340 }
341 }
342 i += 1;
343 }
344 out
345}
346
347fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
350 for key in keys {
351 for tag in tags.iter() {
352 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
353 if parts.len() >= 2 && parts[0] == *key {
354 return Some(parts[1].to_string());
355 }
356 }
357 }
358 None
359}
360
361pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
364 if event.kind.as_u16() != KIND_EMOJI_SET {
365 return None;
366 }
367
368 let identifier = first_tag(&event.tags, &["d"])?;
369 let pubkey = event.pubkey.to_hex();
370 let addr = build_pack_addr(&pubkey, &identifier);
371 let id = match naddr_from_addr(&addr) {
372 Ok(s) => s,
373 Err(e) => {
374 crate::log_warn!(
375 "[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
376 addr, e,
377 );
378 return None;
379 }
380 };
381
382 let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
383 let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
384 let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
385
386 let mut emojis = Vec::new();
387 let mut seen = std::collections::HashSet::new();
388 for tag in event.tags.iter() {
389 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
390 if parts.len() >= 3 && parts[0] == "emoji" {
391 let shortcode = parts[1];
392 if !is_valid_shortcode(shortcode) { continue; }
393 let url = parts[2].trim();
397 if url.is_empty() { continue; }
398 if !seen.insert(shortcode.to_string()) { continue; }
399 emojis.push(PackEmoji {
400 shortcode: shortcode.to_string(),
401 url: url.to_string(),
402 sha256: None,
403 });
404 }
405 }
406
407 if emojis.is_empty() {
408 return None;
409 }
410
411 let is_own = my_pubkey_hex.is_some_and(|me| me == pubkey);
412
413 Some(EmojiPack {
414 id,
415 pubkey,
416 identifier,
417 title,
418 image_url,
419 description,
420 emojis,
421 is_own,
422 updated_at: event.created_at.as_secs(),
423 status: PACK_STATUS_ACTIVE,
424 })
425}
426
427#[derive(Debug, Clone, PartialEq)]
429pub struct PackAddress {
430 pub kind: u16,
431 pub pubkey: PublicKey,
432 pub identifier: String,
433}
434
435pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
439 let mut parts = addr.splitn(3, ':');
440 let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
441 let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
442 let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
443
444 let kind: u16 = kind_str.parse()
445 .map_err(|_| format!("invalid kind: {}", kind_str))?;
446 if kind != KIND_EMOJI_SET {
447 return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
448 }
449 let pubkey = PublicKey::from_hex(pubkey_str)
450 .map_err(|e| format!("invalid pubkey: {}", e))?;
451
452 Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
453}
454
455impl PackAddress {
456 pub fn to_addr_string(&self) -> String {
461 debug_assert_eq!(self.kind, KIND_EMOJI_SET,
462 "PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
463 build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
464 }
465}
466
467pub async fn share_naddr(naddr: &str) -> Result<String, String> {
474 let coordinate = match Nip19::from_bech32(naddr) {
475 Ok(Nip19::Coordinate(c)) => c.coordinate,
476 _ => return Err("not a pack naddr".to_string()),
477 };
478 if coordinate.kind != Kind::Custom(KIND_EMOJI_SET) {
479 return Err("not an emoji pack coordinate".to_string());
480 }
481 let client = nostr_client().ok_or_else(|| "Nostr client not initialized".to_string())?;
482 let mut relays = fetch_author_write_relays(&client, coordinate.public_key).await;
483 if relays.is_empty() {
484 relays = client
487 .relays()
488 .await
489 .into_iter()
490 .filter(|(_, r)| r.capabilities().load().can_write())
491 .map(|(url, _)| url)
492 .collect();
493 }
494 relays.truncate(3);
495 if relays.is_empty() {
496 return Ok(naddr.to_string());
497 }
498 Nip19::Coordinate(Nip19Coordinate { coordinate, relays })
499 .to_bech32()
500 .map_err(|e| format!("encode naddr: {}", e))
501}
502
503pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
507 let parsed = parse_pack_address(addr)?;
508 let coord = nostr_sdk::prelude::nip01::Coordinate {
509 kind: Kind::Custom(parsed.kind),
510 public_key: parsed.pubkey,
511 identifier: parsed.identifier,
512 };
513 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
514 coordinate: coord,
515 relays: Vec::new(),
516 };
517 nostr_sdk::prelude::nip19::Nip19::Coordinate(n19)
518 .to_bech32()
519 .map_err(|e| format!("encode naddr: {}", e))
520}
521
522pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
526 let trimmed = naddr.trim().trim_start_matches("nostr:");
527 let parsed = nostr_sdk::prelude::nip19::Nip19::from_bech32(trimmed)
528 .map_err(|e| format!("invalid naddr: {}", e))?;
529 let coord = match parsed {
530 nostr_sdk::prelude::nip19::Nip19::Coordinate(c) => c,
531 _ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
532 };
533 let kind = coord.kind.as_u16();
534 if kind != KIND_EMOJI_SET {
535 return Err(format!(
536 "expected kind {} (emoji set), got {}",
537 KIND_EMOJI_SET, kind,
538 ));
539 }
540 Ok(PackAddress {
541 kind,
542 pubkey: coord.public_key,
543 identifier: coord.identifier.clone(),
544 })
545}
546
547const THEME_SLOT_TOKEN: &str = "theme_slot";
553
554fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
559 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
560 Ok(v) => v,
561 Err(e) => {
562 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
563 return Vec::new();
564 }
565 };
566 inner.into_iter()
567 .filter_map(|tup| {
568 if tup.len() >= 2 && tup[0] == "a" {
569 parse_pack_address(&tup[1]).ok()
570 } else {
571 None
572 }
573 })
574 .collect()
575}
576
577fn parse_inner_tag_list_with_anchor(plaintext: &str) -> (Vec<String>, Option<String>) {
584 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
585 Ok(v) => v,
586 Err(e) => {
587 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
588 return (Vec::new(), None);
589 }
590 };
591 let mut addrs: Vec<String> = Vec::new();
592 let mut anchor: Option<String> = None;
593 for tup in inner {
594 if tup.len() >= 2 && tup[0] == "a" {
595 if let Ok(pa) = parse_pack_address(&tup[1]) {
598 addrs.push(pa.to_addr_string());
599 }
600 } else if anchor.is_none() && tup.first().map(String::as_str) == Some(THEME_SLOT_TOKEN) {
601 anchor = Some(addrs.last().cloned().unwrap_or_default());
604 }
605 }
606 (addrs, anchor)
607}
608
609pub async fn decrypt_subscribed_addresses(
617 client: &Client,
618 my_pk: &PublicKey,
619 event: &Event,
620) -> Vec<PackAddress> {
621 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
622 Some(plaintext) => parse_inner_tag_list(&plaintext),
623 None => Vec::new(),
624 }
625}
626
627async fn decrypt_emoji_list_plaintext(
630 _client: &Client,
631 my_pk: &PublicKey,
632 event: &Event,
633) -> Option<String> {
634 if event.content.is_empty() {
635 return None;
636 }
637 let signer = match crate::signer::active_signer() {
638 Ok(s) => s,
639 Err(e) => {
640 crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
641 return None;
642 }
643 };
644 match signer.nip44_decrypt_async(my_pk, &event.content).await {
645 Ok(p) => Some(p),
646 Err(e) => {
647 crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
648 None
649 }
650 }
651}
652
653async fn decrypt_subscribed_addresses_with_anchor(
656 client: &Client,
657 my_pk: &PublicKey,
658 event: &Event,
659) -> (Vec<PackAddress>, Option<String>) {
660 match decrypt_emoji_list_plaintext(client, my_pk, event).await {
661 Some(plaintext) => {
662 let (raw, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
663 let addrs = raw.iter().filter_map(|s| parse_pack_address(s).ok()).collect();
664 (addrs, anchor)
665 }
666 None => (Vec::new(), None),
667 }
668}
669
670pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
675 let mut conn = crate::db::get_write_connection_guard_static()?;
676 let tx = conn.transaction()
677 .map_err(|e| format!("Failed to start tx: {}", e))?;
678
679 let addr = pack.addr();
680 tx.execute(
681 "INSERT OR REPLACE INTO emoji_packs
682 (addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
683 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
684 rusqlite::params![
685 addr, pack.pubkey, pack.identifier,
686 pack.title, pack.image_url, pack.description,
687 pack.is_own as i32, pack.updated_at as i64,
688 ],
689 ).map_err(|e| format!("Failed to upsert pack: {}", e))?;
690
691 tx.execute(
695 "DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
696 rusqlite::params![addr],
697 ).map_err(|e| format!("Failed to clear pack items: {}", e))?;
698
699 for (pos, emoji) in pack.emojis.iter().enumerate() {
700 tx.execute(
701 "INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
702 VALUES (?1, ?2, ?3, ?4, ?5)",
703 rusqlite::params![
704 addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
705 ],
706 ).map_err(|e| format!("Failed to insert pack item: {}", e))?;
707 }
708
709 tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
710 Ok(())
711}
712
713pub fn apply_pack_health(addr: &str, outcome: &PackFetchOutcome, now: i64) -> Result<bool, String> {
730 use rusqlite::OptionalExtension;
731 if matches!(outcome, PackFetchOutcome::Unreachable) {
732 return Ok(false);
733 }
734 let mut conn = crate::db::get_write_connection_guard_static()?;
738 let tx = conn
739 .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
740 .map_err(|e| format!("begin pack health tx: {}", e))?;
741
742 let row = tx
743 .query_row(
744 "SELECT status, miss_count, first_missed_at, last_miss_counted_at
745 FROM emoji_packs WHERE addr = ?1",
746 rusqlite::params![addr],
747 |r| {
748 Ok((
749 r.get::<_, i64>(0)?,
750 r.get::<_, i64>(1)?,
751 r.get::<_, i64>(2)?,
752 r.get::<_, i64>(3)?,
753 ))
754 },
755 )
756 .optional()
757 .map_err(|e| format!("read pack health: {}", e))?;
758 let Some((status, miss_count, first_missed_at, last_miss_counted_at)) = row else {
759 return Ok(false);
760 };
761
762 let (new_status, new_miss, new_first, new_last) = match outcome {
763 PackFetchOutcome::Found => (PACK_STATUS_ACTIVE as i64, 0, 0, 0),
764 PackFetchOutcome::Tombstoned => (PACK_STATUS_REVOKED as i64, 0, 0, 0),
765 PackFetchOutcome::CleanMiss => {
766 if status == PACK_STATUS_REVOKED as i64 || now - last_miss_counted_at < PACK_MISS_RATELIMIT_SECS {
767 (status, miss_count, first_missed_at, last_miss_counted_at)
768 } else {
769 let first = if miss_count == 0 { now } else { first_missed_at };
770 let count = miss_count + 1;
771 let promoted = count >= PACK_MISS_PROMOTE_COUNT
772 && now - first >= PACK_MISS_PROMOTE_SECS;
773 (
774 if promoted { PACK_STATUS_MISSING as i64 } else { status },
775 count,
776 first,
777 now,
778 )
779 }
780 }
781 PackFetchOutcome::Unreachable => unreachable!(),
782 };
783
784 let changed = new_status != status;
785 tx.execute(
786 "UPDATE emoji_packs SET status = ?2, miss_count = ?3, first_missed_at = ?4,
787 last_miss_counted_at = ?5,
788 status_changed_at = CASE WHEN status != ?2 THEN ?6 ELSE status_changed_at END
789 WHERE addr = ?1",
790 rusqlite::params![addr, new_status, new_miss, new_first, new_last, now],
791 )
792 .map_err(|e| format!("write pack health: {}", e))?;
793
794 if changed && new_status != PACK_STATUS_ACTIVE as i64 {
797 tx.execute(
798 "DELETE FROM emoji_usage WHERE kind = 1 AND url IN
799 (SELECT url FROM emoji_pack_items WHERE pack_addr = ?1)",
800 rusqlite::params![addr],
801 )
802 .map_err(|e| format!("purge dead pack usage: {}", e))?;
803 }
804
805 tx.commit().map_err(|e| format!("commit pack health: {}", e))?;
806 Ok(changed)
807}
808
809pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
810 let mut conn = crate::db::get_write_connection_guard_static()?;
811 let tx = conn.transaction()
812 .map_err(|e| format!("Failed to start tx: {}", e))?;
813
814 tx.execute("DELETE FROM emoji_pack_subscriptions", [])
815 .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
816
817 let now = web_time::SystemTime::now()
818 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs() as i64;
819 for (pos, addr) in addrs.iter().enumerate() {
823 tx.execute(
824 "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at, position)
825 VALUES (?1, ?2, ?3)",
826 rusqlite::params![addr, now, pos as i64],
827 ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
828 }
829
830 tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
831 Ok(())
832}
833
834pub fn load_subscriptions() -> Result<Vec<String>, String> {
835 let conn = crate::db::get_db_connection_guard_static()?;
836 let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY position ASC, subscribed_at ASC")
837 .map_err(|e| format!("prepare: {}", e))?;
838 let rows = stmt.query_map([], |row| row.get::<_, String>(0))
839 .map_err(|e| format!("query: {}", e))?;
840 let mut out = Vec::new();
841 for r in rows {
842 out.push(r.map_err(|e| format!("row: {}", e))?);
843 }
844 Ok(out)
845}
846
847pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
852 let conn = crate::db::get_db_connection_guard_static()?;
853
854 let mut pack = match conn.query_row(
855 "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at, status
856 FROM emoji_packs WHERE addr = ?1",
857 rusqlite::params![addr],
858 |row| {
859 Ok(EmojiPack {
860 id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
861 pubkey: row.get(0)?,
862 identifier: row.get(1)?,
863 title: row.get(2)?,
864 image_url: row.get(3)?,
865 description: row.get(4)?,
866 is_own: row.get::<_, i32>(5)? != 0,
867 updated_at: row.get::<_, i64>(6)? as u64,
868 emojis: Vec::new(),
869 status: row.get::<_, i64>(7)? as u8,
870 })
871 },
872 ) {
873 Ok(p) => p,
874 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
875 Err(e) => return Err(format!("query cached pack: {}", e)),
876 };
877
878 let mut stmt = conn.prepare(
879 "SELECT shortcode, url, sha256 FROM emoji_pack_items
880 WHERE pack_addr = ?1 ORDER BY position ASC"
881 ).map_err(|e| format!("prepare items: {}", e))?;
882 let rows = stmt.query_map(rusqlite::params![addr], |row| {
883 Ok(PackEmoji {
884 shortcode: row.get(0)?,
885 url: row.get(1)?,
886 sha256: row.get(2)?,
887 })
888 }).map_err(|e| format!("query items: {}", e))?;
889 for r in rows {
890 pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
891 }
892
893 Ok(Some(pack))
894}
895
896pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
901 let conn = crate::db::get_db_connection_guard_static()?;
902
903 let mut packs: HashMap<String, EmojiPack> = HashMap::new();
904 let mut order: Vec<String> = Vec::new();
905
906 {
907 let mut stmt = conn.prepare(
914 "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at, p.status
915 FROM emoji_packs p
916 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
917 ORDER BY s.position ASC"
918 ).map_err(|e| format!("prepare packs: {}", e))?;
919
920 let rows = stmt.query_map([], |row| {
921 let raw_addr: String = row.get(0)?;
925 let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
926 Ok((raw_addr, EmojiPack {
927 id,
928 pubkey: row.get(1)?,
929 identifier: row.get(2)?,
930 title: row.get(3)?,
931 image_url: row.get(4)?,
932 description: row.get(5)?,
933 is_own: row.get::<_, i32>(6)? != 0,
934 updated_at: row.get::<_, i64>(7)? as u64,
935 emojis: Vec::new(),
936 status: row.get::<_, i64>(8)? as u8,
937 }))
938 }).map_err(|e| format!("query packs: {}", e))?;
939
940 for r in rows {
941 let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
942 order.push(raw_addr.clone());
943 packs.insert(raw_addr, pack);
944 }
945 }
946
947 {
948 let mut stmt = conn.prepare(
949 "SELECT pack_addr, shortcode, url, sha256
950 FROM emoji_pack_items
951 ORDER BY pack_addr, position ASC"
952 ).map_err(|e| format!("prepare items: {}", e))?;
953
954 let rows = stmt.query_map([], |row| {
955 Ok((
956 row.get::<_, String>(0)?,
957 PackEmoji {
958 shortcode: row.get(1)?,
959 url: row.get(2)?,
960 sha256: row.get(3)?,
961 },
962 ))
963 }).map_err(|e| format!("query items: {}", e))?;
964
965 for r in rows {
966 let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
967 if let Some(p) = packs.get_mut(&addr) {
968 p.emojis.push(emoji);
969 }
970 }
971 }
972
973 Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
974}
975
976const NIP65_CACHE_TTL_SECS: u64 = 3600;
985const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
988const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
989
990#[derive(Clone)]
991struct CachedRelayList {
992 relays: Vec<RelayUrl>,
993 fetched_at: web_time::Instant,
994 empty: bool,
996 verified: bool,
1000}
1001
1002struct Nip65Cache;
1005
1006fn nip65_cache() -> std::sync::Arc<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> {
1007 crate::db::current_session().scoped::<Nip65Cache, _>()
1008}
1009
1010fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1013 let owner = nip65_cache();
1014 let cache = owner.read().ok()?;
1015 let entry = cache.get(pubkey)?;
1016 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1017 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1018 Some(entry.relays.clone())
1019 } else {
1020 None
1021 }
1022}
1023
1024fn cached_write_relays_verified(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1028 let owner = nip65_cache();
1029 let cache = owner.read().ok()?;
1030 let entry = cache.get(pubkey)?;
1031 if !entry.verified {
1032 return None;
1033 }
1034 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1035 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1036 Some(entry.relays.clone())
1037 } else {
1038 None
1039 }
1040}
1041
1042fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>, verified: bool) {
1044 if let Ok(mut cache) = nip65_cache().write() {
1045 let empty = relays.is_empty();
1046 cache.insert(pubkey, CachedRelayList {
1047 relays,
1048 fetched_at: web_time::Instant::now(),
1049 empty,
1050 verified,
1051 });
1052 }
1053}
1054
1055fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
1059 let mut relays: Vec<RelayUrl> = Vec::new();
1060 for (url, marker) in nostr_sdk::prelude::nip65::extract_relay_list(ev) {
1061 match marker {
1062 None | Some(nostr_sdk::prelude::nip65::RelayMetadata::Write) => {
1063 if !relays.contains(&url) {
1064 relays.push(url.clone());
1065 }
1066 }
1067 Some(nostr_sdk::prelude::nip65::RelayMetadata::Read) => {}
1068 }
1069 }
1070 relays
1071}
1072
1073async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
1078 if let Some(relays) = cached_write_relays(&pubkey) {
1079 return relays;
1080 }
1081
1082 let filter = Filter::new()
1083 .author(pubkey)
1084 .kind(Kind::RelayList)
1085 .limit(1);
1086 let events = match client
1087 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1088 .await
1089 {
1090 Ok(evs) => evs,
1091 Err(_) => {
1092 cache_write_relays(pubkey, Vec::new(), false);
1093 return Vec::new();
1094 }
1095 };
1096
1097 let relays = events.into_iter()
1098 .max_by_key(|e| e.created_at)
1099 .map(|ev| extract_write_relays(&ev))
1100 .unwrap_or_default();
1101 cache_write_relays(pubkey, relays.clone(), true);
1102 relays
1103}
1104
1105async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
1111 let uncached: Vec<PublicKey> = authors.iter()
1112 .filter(|pk| cached_write_relays(pk).is_none())
1113 .copied()
1114 .collect();
1115 if uncached.is_empty() {
1116 return;
1117 }
1118
1119 let filter = Filter::new()
1120 .authors(uncached.iter().copied())
1121 .kind(Kind::RelayList);
1122 let events = match client
1123 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1124 .await
1125 {
1126 Ok(evs) => evs,
1127 Err(_) => return,
1130 };
1131
1132 let mut newest: HashMap<PublicKey, Event> = HashMap::new();
1134 for ev in events {
1135 match newest.get(&ev.pubkey) {
1136 Some(existing) if existing.created_at >= ev.created_at => {}
1137 _ => { newest.insert(ev.pubkey, ev); }
1138 }
1139 }
1140 for pk in uncached {
1141 let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
1142 cache_write_relays(pk, relays, true);
1143 }
1144}
1145
1146async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
1155 let filter = Filter::new()
1156 .author(addr.pubkey)
1157 .kind(Kind::Custom(KIND_EMOJI_SET))
1158 .identifier(&addr.identifier)
1159 .limit(1);
1160 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1161 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1162
1163 match client.fetch_events(filter.clone()).timeout(timeout).await {
1166 Ok(events) => {
1167 if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
1168 if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
1169 return Some(pack);
1170 }
1171 }
1172 }
1173 Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
1174 }
1175
1176 let outbox = fetch_author_write_relays(client, addr.pubkey).await;
1182 if outbox.is_empty() {
1183 return None;
1184 }
1185 fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
1186}
1187
1188async fn fetch_pack_via_isolated_client(
1192 relays: &[RelayUrl],
1193 filter: Filter,
1194 timeout: std::time::Duration,
1195 my_pubkey_hex: Option<&str>,
1196) -> Option<EmojiPack> {
1197 let scratch = crate::nostr_client_builder()
1198 .build();
1199 for r in relays {
1200 let _ = scratch.add_managed_relay(r.as_str()).await;
1201 }
1202 scratch.connect().await;
1203
1204 let result = scratch.fetch_events(filter).timeout(timeout).await;
1205 scratch.shutdown().await;
1207
1208 let events = match result {
1209 Ok(events) => events,
1210 Err(e) => {
1211 crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
1212 return None;
1213 }
1214 };
1215 let event = events.into_iter().max_by_key(|e| e.created_at)?;
1216 parse_pack_from_event(&event, my_pubkey_hex)
1217}
1218
1219fn event_coord(ev: &Event) -> Option<String> {
1238 let d = first_tag(&ev.tags, &["d"])?;
1239 Some(format!("{}:{}", ev.pubkey.to_hex(), d))
1240}
1241fn addr_coord(addr: &PackAddress) -> String {
1242 format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
1243}
1244
1245fn newest_events_by_coord(
1252 events: impl IntoIterator<Item = Event>,
1253 wanted: &std::collections::HashSet<String>,
1254) -> HashMap<String, Event> {
1255 let mut newest: HashMap<String, Event> = HashMap::new();
1256 for ev in events {
1257 if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
1258 let Some(coord) = event_coord(&ev) else { continue; };
1259 if !wanted.contains(&coord) { continue; }
1260 match newest.get(&coord) {
1261 Some(existing) if existing.created_at >= ev.created_at => {}
1262 _ => { newest.insert(coord, ev); }
1263 }
1264 }
1265 newest
1266}
1267
1268fn merge_newest(acc: &mut HashMap<String, Event>, phase: HashMap<String, Event>) {
1271 for (coord, ev) in phase {
1272 match acc.get(&coord) {
1273 Some(existing) if existing.created_at >= ev.created_at => {}
1274 _ => { acc.insert(coord, ev); }
1275 }
1276 }
1277}
1278
1279fn deletion_filter(addrs: &[&PackAddress]) -> Filter {
1282 Filter::new()
1283 .authors(addrs.iter().map(|a| a.pubkey))
1284 .kind(Kind::EventDeletion)
1285 .custom_tags(
1286 SingleLetterTag::LOWERCASE_A,
1287 addrs.iter().map(|a| a.to_addr_string()),
1288 )
1289}
1290
1291fn deletion_matches(ev: &Event, author: &PublicKey, raw_addr: &str) -> bool {
1294 ev.kind == Kind::EventDeletion
1295 && ev.pubkey == *author
1296 && ev.tags.iter().any(|t| {
1297 let s = t.as_slice();
1298 s.len() >= 2 && s[0] == "a" && s[1] == raw_addr
1299 })
1300}
1301
1302async fn connected_read_relays(client: &Client, read_only: bool) -> std::collections::HashSet<String> {
1307 client
1308 .relays()
1309 .await
1310 .iter()
1311 .filter(|(_, r)| r.status() == RelayStatus::Connected && (!read_only || r.capabilities().load().can_read()))
1312 .map(|(url, _)| url.to_string())
1313 .collect()
1314}
1315
1316fn absence_is_clean(
1328 home_ok: bool,
1329 live_relays: usize,
1330 author_writes: Option<&Vec<RelayUrl>>,
1331 outbox_ok: bool,
1332 outbox_live: usize,
1333) -> bool {
1334 if !home_ok || live_relays < 2 {
1335 return false;
1336 }
1337 match author_writes {
1338 None => false,
1339 Some(w) if w.is_empty() => true,
1340 Some(_) => outbox_ok && outbox_live >= 1,
1341 }
1342}
1343
1344fn classify_pack(
1347 newest_ev: Option<&Event>,
1348 newest_deletion: Option<&Event>,
1349 me: Option<&str>,
1350 absence_clean: bool,
1351) -> (PackFetchOutcome, Option<EmojiPack>) {
1352 match newest_ev {
1353 Some(ev) => match parse_pack_from_event(ev, me) {
1354 Some(pack) => {
1355 match newest_deletion {
1358 Some(del) if del.created_at > ev.created_at => {
1359 (PackFetchOutcome::Tombstoned, None)
1360 }
1361 _ => (PackFetchOutcome::Found, Some(pack)),
1362 }
1363 }
1364 None => {
1365 let has_emoji_tags = ev.tags.iter().any(|t| {
1370 t.as_slice().first().map(|k| k == "emoji").unwrap_or(false)
1371 });
1372 if has_emoji_tags {
1373 (PackFetchOutcome::Unreachable, None)
1374 } else {
1375 (PackFetchOutcome::Tombstoned, None)
1376 }
1377 }
1378 },
1379 None if newest_deletion.is_some() => (PackFetchOutcome::Tombstoned, None),
1380 None if absence_clean => (PackFetchOutcome::CleanMiss, None),
1381 None => (PackFetchOutcome::Unreachable, None),
1382 }
1383}
1384
1385struct PackSweep {
1388 packs: Vec<EmojiPack>,
1389 outcomes: HashMap<String, PackFetchOutcome>,
1391}
1392
1393async fn sweep_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> PackSweep {
1399 if addrs.is_empty() {
1400 return PackSweep { packs: Vec::new(), outcomes: HashMap::new() };
1401 }
1402 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1403 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1404 let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1405 let all_refs: Vec<&PackAddress> = addrs.iter().collect();
1406
1407 let home_before = connected_read_relays(client, true).await;
1414 let home_filter = Filter::new()
1415 .authors(addrs.iter().map(|a| a.pubkey))
1416 .kind(Kind::Custom(KIND_EMOJI_SET))
1417 .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1418 let mut newest: HashMap<String, Event> = HashMap::new();
1419 let mut deletions: Vec<Event> = Vec::new();
1420 let mut home_ok = false;
1421 match client.fetch_events(home_filter).timeout(timeout).await {
1422 Ok(events) => {
1423 home_ok = true;
1424 merge_newest(&mut newest, newest_events_by_coord(events, &wanted));
1425 }
1426 Err(e) => {
1427 crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1428 }
1429 }
1430 if home_ok {
1431 match client.fetch_events(deletion_filter(&all_refs)).timeout(timeout).await {
1432 Ok(events) => deletions.extend(events),
1433 Err(e) => crate::log_warn!("[EmojiPacks] home deletion fetch failed: {}", e),
1434 }
1435 }
1436 let home_after = connected_read_relays(client, true).await;
1437 let mut live_relays: std::collections::HashSet<String> =
1438 home_before.intersection(&home_after).cloned().collect();
1439
1440 let misses: Vec<&PackAddress> = addrs.iter()
1444 .filter(|a| !newest.contains_key(&addr_coord(a)))
1445 .collect();
1446 let mut outbox_ok = false;
1447 let mut outbox_live = 0usize;
1448 if !misses.is_empty() {
1449 let miss_authors: Vec<PublicKey> = {
1450 let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1451 v.sort(); v.dedup();
1452 v
1453 };
1454 prefetch_author_write_relays(client, &miss_authors).await;
1457 let mut outbox: Vec<RelayUrl> = Vec::new();
1458 for pk in &miss_authors {
1459 for r in cached_write_relays(pk).unwrap_or_default() {
1460 if !outbox.contains(&r) { outbox.push(r); }
1461 }
1462 }
1463 if !outbox.is_empty() {
1464 let miss_filter = Filter::new()
1465 .authors(misses.iter().map(|a| a.pubkey))
1466 .kind(Kind::Custom(KIND_EMOJI_SET))
1467 .identifiers(misses.iter().map(|a| a.identifier.clone()));
1468 let wanted_misses: std::collections::HashSet<String> =
1469 misses.iter().map(|a| addr_coord(a)).collect();
1470 if let Some((events, dels, live)) = sweep_via_isolated_client(
1471 &outbox, miss_filter, deletion_filter(&misses), timeout,
1472 ).await {
1473 outbox_ok = true;
1474 outbox_live = live.len();
1475 live_relays.extend(live);
1476 merge_newest(&mut newest, newest_events_by_coord(events, &wanted_misses));
1477 deletions.extend(dels);
1478 }
1479 }
1480 }
1481
1482 let mut packs: Vec<EmojiPack> = Vec::new();
1484 let mut outcomes: HashMap<String, PackFetchOutcome> = HashMap::new();
1485 for addr in addrs {
1486 let coord = addr_coord(addr);
1487 let raw_addr = addr.to_addr_string();
1488 let newest_deletion = deletions.iter()
1489 .filter(|d| deletion_matches(d, &addr.pubkey, &raw_addr))
1490 .max_by_key(|d| d.created_at);
1491 let author_writes = cached_write_relays_verified(&addr.pubkey);
1492 let clean = absence_is_clean(
1493 home_ok,
1494 live_relays.len(),
1495 author_writes.as_ref(),
1496 outbox_ok,
1497 outbox_live,
1498 );
1499 let (outcome, pack) = classify_pack(
1500 newest.get(&coord),
1501 newest_deletion,
1502 me.as_deref(),
1503 clean,
1504 );
1505 if let Some(p) = pack {
1506 packs.push(p);
1507 }
1508 outcomes.insert(raw_addr, outcome);
1509 }
1510
1511 PackSweep { packs, outcomes }
1512}
1513
1514async fn sweep_via_isolated_client(
1521 relays: &[RelayUrl],
1522 pack_filter: Filter,
1523 del_filter: Filter,
1524 timeout: std::time::Duration,
1525) -> Option<(Vec<Event>, Vec<Event>, std::collections::HashSet<String>)> {
1526 let scratch = crate::nostr_client_builder()
1527 .build();
1528 for r in relays {
1529 let _ = scratch.add_managed_relay(r.as_str()).await;
1530 }
1531 scratch.connect().await;
1532 let connect_deadline = web_time::Instant::now() + std::time::Duration::from_secs(8);
1537 let before = loop {
1538 let connected = connected_read_relays(&scratch, false).await;
1539 if !connected.is_empty() || web_time::Instant::now() >= connect_deadline {
1540 break connected;
1541 }
1542 crate::rt::time::sleep(std::time::Duration::from_millis(200)).await;
1543 };
1544 let result = scratch.fetch_events(pack_filter).timeout(timeout).await;
1545 let dels = match &result {
1546 Ok(_) => scratch.fetch_events(del_filter).timeout(timeout).await.ok(),
1547 Err(_) => None,
1548 };
1549 let after = connected_read_relays(&scratch, false).await;
1550 scratch.shutdown().await;
1551
1552 match result {
1553 Ok(events) => Some((
1554 events.into_iter().collect(),
1555 dels.map(|d| d.into_iter().collect()).unwrap_or_default(),
1556 before.intersection(&after).cloned().collect(),
1557 )),
1558 Err(e) => {
1559 crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1560 None
1561 }
1562 }
1563}
1564
1565pub async fn fetch_subscribed_packs(
1574 client: &Client,
1575 my_pubkey: PublicKey,
1576) -> Result<Vec<EmojiPack>, String> {
1577 crate::db::scoped(async move {
1578 let list_filter = Filter::new()
1579 .author(my_pubkey)
1580 .kind(Kind::Custom(KIND_EMOJI_LIST))
1581 .limit(1);
1582
1583 let list_events = client
1584 .fetch_events(list_filter).timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1585 .await
1586 .map_err(|e| format!("fetch kind 10030: {}", e))?;
1587
1588
1589 let local_addrs = || -> Vec<PackAddress> {
1597 load_subscriptions()
1598 .unwrap_or_default()
1599 .into_iter()
1600 .filter_map(|s| parse_pack_address(&s).ok())
1601 .collect()
1602 };
1603 let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1604 .ok()
1605 .flatten()
1606 .and_then(|s| s.parse().ok())
1607 .unwrap_or(0);
1608
1609 let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1610 let mut fetched_anchor: Option<String> = None;
1613 let addrs: Vec<PackAddress> = match list_event {
1614 Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1615 crate::log_debug!(
1616 "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1617 ev.created_at.as_secs(), our_last_publish,
1618 );
1619 local_addrs()
1620 }
1621 Some(ev) => {
1622 let (a, anchor) = decrypt_subscribed_addresses_with_anchor(client, &my_pubkey, &ev).await;
1623 fetched_anchor = anchor;
1624 a
1625 }
1626 None => {
1627 crate::log_debug!(
1628 "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1629 );
1630 local_addrs()
1631 }
1632 };
1633
1634 let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1635
1636 let PackSweep { packs: fresh, outcomes } = sweep_packs_from_relays(client, &addrs).await;
1643 let tombstoned = outcomes.values().filter(|o| matches!(o, PackFetchOutcome::Tombstoned)).count();
1644 let unresolved = addrs.len().saturating_sub(fresh.len()).saturating_sub(tombstoned);
1645 if unresolved > 0 {
1646 crate::log_warn!(
1647 "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1648 unresolved,
1649 );
1650 }
1651 if tombstoned > 0 {
1652 crate::log_info!("[EmojiPacks] {} subscribed pack(s) deleted by their creator", tombstoned);
1653 }
1654
1655
1656 let me_hex = my_pubkey.to_hex();
1662 let now = web_time::SystemTime::now()
1663 .duration_since(web_time::UNIX_EPOCH)
1664 .map(|d| d.as_secs() as i64)
1665 .unwrap_or(0);
1666 let mut health_changed = false;
1667 for addr in &addrs {
1668 if addr.pubkey.to_hex() == me_hex { continue; }
1669 let raw = addr.to_addr_string();
1670 let Some(outcome) = outcomes.get(&raw) else { continue; };
1671 match apply_pack_health(&raw, outcome, now) {
1672 Ok(true) => {
1673 health_changed = true;
1674 crate::log_info!(
1675 "[EmojiPacks] pack `{}` health changed ({:?})",
1676 addr.identifier, outcome,
1677 );
1678 }
1679 Ok(false) => {}
1680 Err(e) => crate::log_warn!("[EmojiPacks] health update for `{}` failed: {}", addr.identifier, e),
1681 }
1682 }
1683 for pack in &fresh {
1684 if let Err(e) = save_pack(pack) {
1685 crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1686 }
1687 }
1688 let list_changed = load_subscriptions().unwrap_or_default() != addr_strings
1693 || fetched_anchor.as_deref().is_some_and(|a| a != load_theme_slot_anchor());
1694
1695 if let Err(e) = save_subscriptions(&addr_strings) {
1699 crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1700 }
1701 if let Some(anchor) = fetched_anchor {
1704 if let Err(e) = save_theme_slot_anchor(&anchor) {
1705 crate::log_warn!("[EmojiPacks] save theme slot anchor failed: {}", e);
1706 }
1707 }
1708 if health_changed || list_changed {
1709 crate::traits::emit_event("emoji_packs_updated", &());
1710 }
1711
1712 crate::log_info!(
1713 "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1714 fresh.len(),
1715 addrs.len(),
1716 if unresolved > 0 {
1717 format!(" ({} via cache)", unresolved)
1718 } else {
1719 String::new()
1720 },
1721 );
1722
1723 load_all_packs()
1726 })
1727 .await
1728}
1729
1730pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1735 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1736 let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1737 fetch_subscribed_packs(&client, me).await
1738}
1739
1740pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1744 let addr = parse_naddr(naddr)?;
1745 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1746 fetch_pack_from_relays(&client, &addr).await
1747 .ok_or_else(|| {
1748 crate::log_debug!("[EmojiPacks] preview miss: {}:{}", addr.pubkey.to_hex(), addr.identifier);
1751 "Pack not found on any relay".to_string()
1752 })
1753}
1754
1755pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1763 crate::db::scoped(async move {
1764 let addr = parse_naddr(naddr)?;
1765 let coord = addr.to_addr_string();
1766
1767 if let Some(cached) = load_cached_pack(&coord)? {
1770 let naddr_owned = naddr.to_string();
1771 crate::db::spawn_bound(async move {
1772 let Some(client) = nostr_client() else { return };
1773 if let Ok(parsed) = parse_naddr(&naddr_owned) {
1774 if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1775 if fresh.updated_at > cached.updated_at {
1776 if let Err(e) = save_pack(&fresh) {
1777 crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1778 } else {
1779 crate::traits::emit_event("emoji_packs_updated", &());
1780 }
1781 }
1782 }
1783 }
1784 });
1785 return Ok(Some(cached));
1786 }
1787
1788 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1790 match fetch_pack_from_relays(&client, &addr).await {
1791 Some(pack) => {
1792 if let Err(e) = save_pack(&pack) {
1796 crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1797 }
1798 Ok(Some(pack))
1799 }
1800 None => Ok(None),
1801 }
1802 })
1803 .await
1804}
1805
1806const THEME_SLOT_ANCHOR_KEY: &str = "emoji_theme_slot_anchor";
1812
1813fn load_theme_slot_anchor() -> String {
1815 crate::db::settings::get_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string())
1816 .ok()
1817 .flatten()
1818 .unwrap_or_default()
1819}
1820
1821fn save_theme_slot_anchor(raw_addr: &str) -> Result<(), String> {
1823 crate::db::settings::set_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string(), raw_addr.to_string())
1824}
1825
1826fn build_inner_tags_with_marker(addrs: &[String], anchor: &str) -> Vec<Vec<String>> {
1831 let mut inner: Vec<Vec<String>> = Vec::with_capacity(addrs.len() + 1);
1832 let mut placed = false;
1833 if anchor.is_empty() {
1834 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1835 placed = true;
1836 }
1837 for addr in addrs {
1838 inner.push(vec!["a".to_string(), addr.clone()]);
1839 if !placed && addr == anchor {
1840 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1841 placed = true;
1842 }
1843 }
1844 if !placed {
1847 inner.insert(0, vec![THEME_SLOT_TOKEN.to_string()]);
1848 }
1849 inner
1850}
1851
1852pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1863 let addrs = load_subscriptions()?;
1864 let anchor = load_theme_slot_anchor();
1865 let my_pk = crate::state::my_public_key()
1866 .ok_or_else(|| "Not logged in".to_string())?;
1867
1868 let inner_tags = build_inner_tags_with_marker(&addrs, &anchor);
1869 let plaintext = serde_json::to_string(&inner_tags)
1870 .map_err(|e| format!("Serialise emoji list: {}", e))?;
1871
1872 let signer = crate::signer::active_signer()
1873 .map_err(|e| format!("Signer unavailable: {}", e))?;
1874 let content = signer.nip44_encrypt_async(&my_pk, &plaintext).await
1875 .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1876
1877 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1878 crate::sign_and_send(client, builder).await
1879 .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1880
1881 crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1882 Ok(())
1883}
1884
1885const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1889
1890static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1891
1892pub fn republish_emoji_list_debounced() {
1896 use std::sync::atomic::Ordering;
1897 let _ = crate::db::settings::set_sql_setting(
1903 EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1904 Timestamp::now().as_secs().to_string(),
1905 );
1906 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1907 crate::db::spawn_bound(async move {
1908 crate::rt::time::sleep(std::time::Duration::from_millis(800)).await;
1909 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1910 let client = match nostr_client() {
1911 Some(c) => c,
1912 None => return,
1913 };
1914 if let Err(e) = publish_emoji_list(&client).await {
1915 crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1916 crate::rt::time::sleep(std::time::Duration::from_secs(5)).await;
1917 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1918 if let Err(e2) = publish_emoji_list(&client).await {
1919 crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1920 }
1921 }
1922 });
1923}
1924
1925pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1929 crate::db::scoped(async move {
1930 let pack = fetch_pack_by_naddr(naddr).await?;
1931
1932 let pack_addr = pack.addr();
1935 {
1936 let existing_subs = load_subscriptions()?;
1937 let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
1938 let cap = effective_max_equipped_packs();
1939 if is_new && existing_subs.len() >= cap {
1940 return Err(format!(
1941 "You can equip at most {} packs. Remove one to add another.",
1942 cap,
1943 ));
1944 }
1945 }
1946
1947 save_pack(&pack)?;
1948
1949 let mut subs = load_subscriptions()?;
1950 if !subs.iter().any(|a| a == &pack_addr) {
1951 subs.push(pack_addr.clone());
1952 }
1953 save_subscriptions(&subs)?;
1954
1955 republish_emoji_list_debounced();
1956 crate::traits::emit_event("emoji_packs_updated", &());
1957
1958 Ok(pack)
1959 })
1960 .await
1961}
1962
1963fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1972 if pack.identifier.is_empty() {
1973 return Err("pack identifier required".to_string());
1974 }
1975 let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1976 .tag(Tag::custom("d", [pack.identifier.clone()]))
1977 .tag(Tag::custom("client", ["vector"]));
1980
1981 if !pack.title.is_empty() {
1983 builder = builder
1984 .tag(Tag::custom("title", [pack.title.clone()]))
1985 .tag(Tag::custom("name", [pack.title.clone()]));
1986 }
1987 if !pack.image_url.is_empty() {
1988 builder = builder
1989 .tag(Tag::custom("image", [pack.image_url.clone()]))
1990 .tag(Tag::custom("picture", [pack.image_url.clone()]));
1991 }
1992 if !pack.description.is_empty() {
1993 builder = builder
1994 .tag(Tag::custom("description", [pack.description.clone()]))
1995 .tag(Tag::custom("about", [pack.description.clone()]));
1996 }
1997
1998 for e in &pack.emojis {
1999 if e.shortcode.is_empty() || e.url.is_empty() { continue; }
2000 builder = builder.tag(Tag::custom(
2001 "emoji",
2002 [e.shortcode.clone(), e.url.clone()],
2003 ));
2004 }
2005 Ok(builder)
2006}
2007
2008pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
2013 crate::db::scoped(async move {
2014 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2015 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2016
2017 let emoji_cap = effective_max_emojis_per_pack();
2020 if pack.emojis.len() > emoji_cap {
2021 return Err(format!(
2022 "A pack can hold at most {} emojis.",
2023 emoji_cap,
2024 ));
2025 }
2026
2027 let mut to_save = pack.clone();
2030 to_save.pubkey = my_pk.to_hex();
2031 to_save.is_own = true;
2032 let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
2033 to_save.id = naddr_from_addr(&raw_addr)?;
2034 to_save.updated_at = web_time::SystemTime::now()
2035 .duration_since(web_time::UNIX_EPOCH).unwrap().as_secs();
2036
2037 {
2040 let existing_subs = load_subscriptions()?;
2041 let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
2042 let cap = effective_max_equipped_packs();
2043 if is_new && existing_subs.len() >= cap {
2044 return Err(format!(
2045 "You can equip at most {} packs. Remove one to add another.",
2046 cap,
2047 ));
2048 }
2049 }
2050
2051 let builder = build_pack_event(&to_save)?;
2052 crate::sign_and_send(&client, builder).await
2053 .map_err(|e| format!("publish kind 30030: {}", e))?;
2054
2055
2056 save_pack(&to_save)?;
2057
2058 let mut subs = load_subscriptions()?;
2061 if !subs.iter().any(|a| a == &raw_addr) {
2062 subs.push(raw_addr.clone());
2063 save_subscriptions(&subs)?;
2064 republish_emoji_list_debounced();
2065 }
2066
2067 crate::traits::emit_event("emoji_packs_updated", &());
2068 crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
2069 to_save.identifier, to_save.emojis.len());
2070
2071 Ok(to_save)
2072 })
2073 .await
2074}
2075
2076pub async fn delete_own_pack(id: &str) -> Result<(), String> {
2080 crate::db::scoped(async move {
2081 let parsed = parse_naddr(id)?;
2082 let raw_addr = parsed.to_addr_string();
2083 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2084 if parsed.pubkey != my_pk {
2085 return Err("Cannot delete a pack you don't own".to_string());
2086 }
2087 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2088
2089 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2090 .tag(Tag::custom("d", [parsed.identifier.clone()]));
2091 crate::sign_and_send(&client, builder).await
2092 .map_err(|e| format!("publish empty kind 30030: {}", e))?;
2093
2094
2095 {
2099 let mut conn = crate::db::get_write_connection_guard_static()?;
2100 let tx = conn.transaction()
2101 .map_err(|e| format!("begin delete tx: {}", e))?;
2102 tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
2103 rusqlite::params![raw_addr])
2104 .map_err(|e| format!("drop subscription: {}", e))?;
2105 tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
2106 rusqlite::params![raw_addr])
2107 .map_err(|e| format!("drop pack row: {}", e))?;
2108 tx.commit()
2109 .map_err(|e| format!("commit delete tx: {}", e))?;
2110 }
2111
2112 republish_emoji_list_debounced();
2113 crate::traits::emit_event("emoji_packs_updated", &());
2114 crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
2115 Ok(())
2116 })
2117 .await
2118}
2119
2120pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
2124 crate::db::scoped(async move {
2125 let raw_addr = parse_naddr(id)?.to_addr_string();
2126 let mut subs = load_subscriptions()?;
2127 let before = subs.len();
2128 subs.retain(|a| a != &raw_addr);
2129 if subs.len() == before {
2130 return Ok(()); }
2132 save_subscriptions(&subs)?;
2133 republish_emoji_list_debounced();
2134 crate::traits::emit_event("emoji_packs_updated", &());
2135 Ok(())
2136 })
2137 .await
2138}
2139
2140pub fn reorder_emoji_packs(ordered_ids: Vec<String>) -> Result<(), String> {
2149 let session = crate::db::current_session();
2150
2151 let mut real_addrs: Vec<String> = Vec::with_capacity(ordered_ids.len());
2152 let mut anchor = String::new();
2153 let mut marker_present = false;
2154 for id in &ordered_ids {
2155 if id == THEME_SLOT_TOKEN {
2156 marker_present = true;
2157 anchor = real_addrs.last().cloned().unwrap_or_default();
2158 } else {
2159 real_addrs.push(parse_naddr(id)?.to_addr_string());
2160 }
2161 }
2162
2163 if !session.is_live() {
2164 return Err("Account swapped during reorder — aborted".to_string());
2165 }
2166 save_subscriptions(&real_addrs)?;
2167 if marker_present {
2171 save_theme_slot_anchor(&anchor)?;
2172 }
2173 republish_emoji_list_debounced();
2174 crate::traits::emit_event("emoji_packs_updated", &());
2175 Ok(())
2176}
2177
2178pub fn get_theme_slot_anchor() -> Result<String, String> {
2182 let raw = load_theme_slot_anchor();
2183 if raw.is_empty() {
2184 return Ok(String::new());
2185 }
2186 Ok(naddr_from_addr(&raw).unwrap_or_default())
2187}
2188
2189#[cfg(test)]
2194mod tests {
2195 use super::*;
2196
2197 fn keys() -> Keys {
2198 Keys::generate()
2199 }
2200
2201 fn build_pack_event(
2202 k: &Keys,
2203 d: &str,
2204 title_tag: Option<(&str, &str)>,
2205 image_tag: Option<(&str, &str)>,
2206 desc_tag: Option<(&str, &str)>,
2207 emojis: &[(&str, &str)],
2208 ) -> Event {
2209 let mut tags: Vec<Tag> = Vec::new();
2210 tags.push(Tag::custom("d", [d]));
2211 if let Some((key, val)) = title_tag {
2212 tags.push(Tag::custom(key, [val]));
2213 }
2214 if let Some((key, val)) = image_tag {
2215 tags.push(Tag::custom(key, [val]));
2216 }
2217 if let Some((key, val)) = desc_tag {
2218 tags.push(Tag::custom(key, [val]));
2219 }
2220 for (code, url) in emojis {
2221 tags.push(Tag::custom("emoji", [*code, *url]));
2222 }
2223 EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2224 .tags(tags)
2225 .finalize(k)
2226 .unwrap()
2227 }
2228
2229 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2230 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2231 crate::db::close_database();
2232 crate::db::clear_id_caches();
2235 let tmp = tempfile::tempdir().unwrap();
2236 let account = keys().public_key().to_bech32().unwrap();
2237 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2238 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
2239 crate::db::set_current_account(account.clone()).unwrap();
2240 crate::db::init_database(&account).unwrap();
2241 (tmp, guard)
2242 }
2243
2244 fn seed_pack(k: &Keys, d: &str, emojis: &[(&str, &str)]) -> String {
2246 let ev = build_pack_event(k, d, None, None, None, emojis);
2247 let pack = parse_pack_from_event(&ev, None).unwrap();
2248 save_pack(&pack).unwrap();
2249 pack.addr()
2250 }
2251
2252 fn seed_usage(url: &str) {
2253 let conn = crate::db::get_write_connection_guard_static().unwrap();
2254 conn.execute(
2255 "INSERT INTO emoji_usage (kind, id, url, score, last_used) VALUES (1, ?1, ?1, 1.0, 0)",
2256 rusqlite::params![url],
2257 ).unwrap();
2258 }
2259
2260 fn usage_count() -> i64 {
2261 let conn = crate::db::get_db_connection_guard_static().unwrap();
2262 conn.query_row("SELECT COUNT(*) FROM emoji_usage", [], |r| r.get(0)).unwrap()
2263 }
2264
2265 fn pack_status(addr: &str) -> u8 {
2266 load_cached_pack(addr).unwrap().unwrap().status
2267 }
2268
2269 fn build_deletion_event(k: &Keys, addr: &str) -> Event {
2270 EventBuilder::new(Kind::EventDeletion, "")
2271 .tag(Tag::custom("a", [addr]))
2272 .finalize(k)
2273 .unwrap()
2274 }
2275
2276 #[test]
2277 fn classify_verdict_table() {
2278 let k = keys();
2279 let live = build_pack_event(&k, "p", None, None, None, &[("a", "https://e.x/a.png")]);
2280 let empty = build_pack_event(&k, "p", None, None, None, &[]);
2281 let addr = format!("30030:{}:p", k.public_key().to_hex());
2282 let del = build_deletion_event(&k, &addr);
2283
2284 let (o, p) = classify_pack(Some(&live), None, None, false);
2286 assert_eq!(o, PackFetchOutcome::Found);
2287 assert!(p.is_some());
2288
2289 let (o, _) = classify_pack(Some(&empty), None, None, true);
2291 assert_eq!(o, PackFetchOutcome::Tombstoned);
2292
2293 let (o, _) = classify_pack(None, Some(&del), None, true);
2295 assert_eq!(o, PackFetchOutcome::Tombstoned);
2296
2297 let ts = Timestamp::from_secs(1_700_000_000);
2300 let live_pinned = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2301 .tags(vec![
2302 Tag::custom("d", ["p"]),
2303 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2304 ])
2305 .custom_created_at(ts)
2306 .finalize(&k)
2307 .unwrap();
2308 let del_at = |secs: u64| {
2309 EventBuilder::new(Kind::EventDeletion, "")
2310 .tag(Tag::custom("a", [addr.as_str()]))
2311 .custom_created_at(Timestamp::from_secs(secs))
2312 .finalize(&k)
2313 .unwrap()
2314 };
2315
2316 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_000)), None, false);
2319 assert_eq!(o, PackFetchOutcome::Found);
2320 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_001)), None, false);
2322 assert_eq!(o, PackFetchOutcome::Tombstoned);
2323 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_699_999_999)), None, false);
2325 assert_eq!(o, PackFetchOutcome::Found);
2326
2327 let (o, _) = classify_pack(None, None, None, true);
2329 assert_eq!(o, PackFetchOutcome::CleanMiss);
2330 let (o, _) = classify_pack(None, None, None, false);
2331 assert_eq!(o, PackFetchOutcome::Unreachable);
2332 }
2333
2334 #[test]
2335 fn classify_invalid_emojis_is_not_a_tombstone() {
2336 let k = keys();
2339 let ev = build_pack_event(&k, "weird", None, None, None, &[("bad!code", "https://e.x/a.png")]);
2340 assert!(parse_pack_from_event(&ev, None).is_none(), "precondition: unparseable");
2341 let (o, _) = classify_pack(Some(&ev), None, None, true);
2342 assert_eq!(o, PackFetchOutcome::Unreachable);
2343 }
2344
2345 #[test]
2346 fn absence_gate_table() {
2347 let some_writes = vec![RelayUrl::parse("wss://relay.author.example").unwrap()];
2348 let no_writes: Vec<RelayUrl> = Vec::new();
2349
2350 assert!(!absence_is_clean(false, 5, Some(&no_writes), true, 1));
2352 assert!(!absence_is_clean(true, 1, Some(&no_writes), true, 1));
2353 assert!(!absence_is_clean(true, 3, None, true, 1));
2356 assert!(absence_is_clean(true, 2, Some(&no_writes), false, 0));
2358 assert!(!absence_is_clean(true, 3, Some(&some_writes), false, 0));
2360 assert!(!absence_is_clean(true, 3, Some(&some_writes), true, 0));
2361 assert!(absence_is_clean(true, 3, Some(&some_writes), true, 1));
2362 }
2363
2364 #[test]
2365 fn deletion_matching_is_author_bound() {
2366 let author = keys();
2367 let stranger = keys();
2368 let addr = format!("30030:{}:p", author.public_key().to_hex());
2369 let forged = build_deletion_event(&stranger, &addr);
2371 assert!(!deletion_matches(&forged, &author.public_key(), &addr));
2372 let real = build_deletion_event(&author, &addr);
2373 assert!(deletion_matches(&real, &author.public_key(), &addr));
2374 }
2375
2376 #[test]
2377 fn tombstone_revokes_immediately_and_purges_frecency() {
2378 let (_tmp, _guard) = init_test_db();
2379 let k = keys();
2380 let addr = seed_pack(&k, "deadpack", &[("boom", "https://e.x/boom.png")]);
2381 seed_usage("https://e.x/boom.png");
2382 seed_usage("https://e.x/unrelated.png");
2383
2384 let changed = apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2385 assert!(changed, "tombstone must flip status in one sweep");
2386 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2387 assert_eq!(usage_count(), 1, "only the dead pack's usage rows purge");
2388 }
2389
2390 #[test]
2391 fn found_self_heals_from_revoked() {
2392 let (_tmp, _guard) = init_test_db();
2393 let k = keys();
2394 let addr = seed_pack(&k, "phoenix", &[("rise", "https://e.x/r.png")]);
2395 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2396 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2397
2398 let changed = apply_pack_health(&addr, &PackFetchOutcome::Found, 2_000).unwrap();
2399 assert!(changed);
2400 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "a republished pack revives");
2401 }
2402
2403 #[test]
2404 fn clean_miss_gauntlet_rate_limit_and_promotion() {
2405 let (_tmp, _guard) = init_test_db();
2406 let k = keys();
2407 let addr = seed_pack(&k, "fading", &[("bye", "https://e.x/bye.png")]);
2408 let t0: i64 = 1_000_000;
2409 let h = 3_600;
2410
2411 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0).unwrap());
2413 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + h).unwrap());
2414 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 13 * h).unwrap());
2416 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 26 * h).unwrap());
2417 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "count alone must not promote");
2418 let changed = apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 49 * h).unwrap();
2420 assert!(changed);
2421 assert_eq!(pack_status(&addr), PACK_STATUS_MISSING);
2422 }
2423
2424 #[test]
2425 fn unreachable_never_moves_counters() {
2426 let (_tmp, _guard) = init_test_db();
2427 let k = keys();
2428 let addr = seed_pack(&k, "offline", &[("zz", "https://e.x/z.png")]);
2429 for t in [1_000i64, 200_000, 400_000, 800_000] {
2430 assert!(!apply_pack_health(&addr, &PackFetchOutcome::Unreachable, t).unwrap());
2431 }
2432 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE);
2433 let conn = crate::db::get_db_connection_guard_static().unwrap();
2434 let miss: i64 = conn.query_row(
2435 "SELECT miss_count FROM emoji_packs WHERE addr = ?1",
2436 rusqlite::params![addr], |r| r.get(0),
2437 ).unwrap();
2438 assert_eq!(miss, 0, "offline sweeps must not accrue misses");
2439 }
2440
2441 #[test]
2442 fn revoked_ignores_clean_misses() {
2443 let (_tmp, _guard) = init_test_db();
2444 let k = keys();
2445 let addr = seed_pack(&k, "sealed", &[("x", "https://e.x/x.png")]);
2446 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2447 for t in [100_000i64, 300_000, 600_000] {
2448 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t).unwrap());
2449 }
2450 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED, "misses can't downgrade a tombstone verdict");
2451 }
2452
2453 #[test]
2454 fn empty_pack_event_is_the_tombstone_shape() {
2455 let k = keys();
2459 let ev = build_pack_event(&k, "gone", None, None, None, &[]);
2460 assert!(parse_pack_from_event(&ev, None).is_none());
2461 }
2462
2463 #[test]
2464 fn parse_pack_reads_nip51_spec_tags() {
2465 let k = keys();
2466 let ev = build_pack_event(
2467 &k, "myPack",
2468 Some(("title", "Spec Pack")),
2469 Some(("image", "https://example.com/p.png")),
2470 Some(("description", "specd")),
2471 &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
2472 );
2473 let pack = parse_pack_from_event(&ev, None).unwrap();
2474 assert_eq!(pack.identifier, "myPack");
2475 assert_eq!(pack.title, "Spec Pack");
2476 assert_eq!(pack.image_url, "https://example.com/p.png");
2477 assert_eq!(pack.description, "specd");
2478 assert_eq!(pack.emojis.len(), 2);
2479 assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
2480 }
2481
2482 #[test]
2483 fn own_pack_event_carries_the_vector_client_tag() {
2484 let k = keys();
2487 let ev = build_pack_event(&k, "myPack", Some(("title", "Mine")), None, None, &[("smile", "https://e.x/s.png")]);
2488 let pack = parse_pack_from_event(&ev, None).unwrap();
2489 let built = super::build_pack_event(&pack).unwrap().finalize(&k).unwrap();
2490 let has_client = built.tags.iter().any(|t| {
2491 let s = t.as_slice();
2492 s.first().map(String::as_str) == Some("client") && s.get(1).map(String::as_str) == Some("vector")
2493 });
2494 assert!(has_client, "own pack event must carry the [\"client\",\"vector\"] tag");
2495 }
2496
2497 #[test]
2498 fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
2499 let k = keys();
2500 let ev = build_pack_event(
2501 &k, "ditto",
2502 Some(("name", "Ditto Pack")),
2503 Some(("picture", "https://example.com/d.png")),
2504 Some(("about", "ditto-style")),
2505 &[("yes", "https://e.x/y.png")],
2506 );
2507 let pack = parse_pack_from_event(&ev, None).unwrap();
2508 assert_eq!(pack.title, "Ditto Pack");
2509 assert_eq!(pack.image_url, "https://example.com/d.png");
2510 assert_eq!(pack.description, "ditto-style");
2511 }
2512
2513 #[test]
2514 fn parse_pack_prefers_spec_tags_over_ditto() {
2515 let k = keys();
2516 let mut tags: Vec<Tag> = vec![
2517 Tag::custom("d", ["both"]),
2518 Tag::custom("title", ["SpecTitle"]),
2519 Tag::custom("name", ["DittoName"]),
2520 Tag::custom("image", ["spec.png"]),
2521 Tag::custom("picture", ["ditto.png"]),
2522 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2523 ];
2524 tags.extend(std::iter::empty());
2525 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2526 .tags(tags).finalize(&k).unwrap();
2527 let pack = parse_pack_from_event(&ev, None).unwrap();
2528 assert_eq!(pack.title, "SpecTitle");
2529 assert_eq!(pack.image_url, "spec.png");
2530 }
2531
2532 #[test]
2533 fn parse_pack_returns_none_without_d_tag() {
2534 let k = keys();
2535 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2536 .tags(vec![
2537 Tag::custom("title", ["No D"]),
2538 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2539 ])
2540 .finalize(&k).unwrap();
2541 assert!(parse_pack_from_event(&ev, None).is_none());
2542 }
2543
2544 #[test]
2545 fn parse_pack_returns_none_when_no_valid_emojis() {
2546 let k = keys();
2547 let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
2548 assert!(parse_pack_from_event(&ev, None).is_none());
2549 }
2550
2551 #[test]
2552 fn parse_pack_rejects_invalid_shortcodes() {
2553 let k = keys();
2554 let ev = build_pack_event(
2555 &k, "mix", Some(("title", "Mix")), None, None,
2556 &[("ok_name", "https://e.x/a.png"),
2557 ("bad name", "https://e.x/b.png"),
2558 ("colons:no", "https://e.x/c.png"),
2559 ("", "https://e.x/d.png"),
2560 ("dash-ok", "https://e.x/e.png")],
2561 );
2562 let pack = parse_pack_from_event(&ev, None).unwrap();
2563 let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
2564 assert_eq!(codes, vec!["ok_name", "dash-ok"]);
2565 }
2566
2567 #[test]
2568 fn parse_pack_dedupes_shortcodes_first_wins() {
2569 let k = keys();
2570 let ev = build_pack_event(
2571 &k, "dup", Some(("title", "Dup")), None, None,
2572 &[("smile", "https://e.x/first.png"),
2573 ("smile", "https://e.x/second.png")],
2574 );
2575 let pack = parse_pack_from_event(&ev, None).unwrap();
2576 assert_eq!(pack.emojis.len(), 1);
2577 assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
2578 }
2579
2580 #[test]
2581 fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
2582 let k = keys();
2583 let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
2584 &[("a", "https://e.x/a.png")]);
2585 let my_hex = k.public_key().to_hex();
2586 let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
2587 assert!(pack_mine.is_own);
2588
2589 let stranger = keys().public_key().to_hex();
2590 let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
2591 assert!(!pack_other.is_own);
2592 }
2593
2594 #[test]
2595 fn pack_address_to_string_round_trips() {
2596 let k = keys();
2597 let hex = k.public_key().to_hex();
2598 let addr = PackAddress {
2599 kind: 30030,
2600 pubkey: k.public_key(),
2601 identifier: "myPack".to_string(),
2602 };
2603 assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
2604 let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
2605 assert_eq!(parsed, addr);
2606 }
2607
2608 #[test]
2609 fn parse_naddr_round_trips_kind_30030_coordinate() {
2610 let k = keys();
2613 let coord = nostr_sdk::prelude::nip01::Coordinate {
2614 kind: Kind::Custom(30030),
2615 public_key: k.public_key(),
2616 identifier: "trip".to_string(),
2617 };
2618 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2619 coordinate: coord,
2620 relays: Vec::new(),
2621 };
2622 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2623 let parsed = parse_naddr(&naddr).unwrap();
2624 assert_eq!(parsed.kind, 30030);
2625 assert_eq!(parsed.pubkey, k.public_key());
2626 assert_eq!(parsed.identifier, "trip");
2627 }
2628
2629 #[test]
2630 fn parse_naddr_rejects_non_30030_kinds() {
2631 let k = keys();
2632 let coord = nostr_sdk::prelude::nip01::Coordinate {
2633 kind: Kind::Custom(30023), public_key: k.public_key(),
2635 identifier: "essay".to_string(),
2636 };
2637 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2638 coordinate: coord,
2639 relays: Vec::new(),
2640 };
2641 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2642 let err = parse_naddr(&naddr).unwrap_err();
2643 assert!(err.contains("expected kind 30030"),
2644 "expected kind-rejection error, got: {}", err);
2645 }
2646
2647 #[test]
2648 fn parse_naddr_strips_nostr_uri_prefix() {
2649 let k = keys();
2650 let coord = nostr_sdk::prelude::nip01::Coordinate {
2651 kind: Kind::Custom(30030),
2652 public_key: k.public_key(),
2653 identifier: "prefixed".to_string(),
2654 };
2655 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2656 coordinate: coord,
2657 relays: Vec::new(),
2658 };
2659 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2660 let with_prefix = format!("nostr:{}", naddr);
2661 let parsed = parse_naddr(&with_prefix).unwrap();
2662 assert_eq!(parsed.identifier, "prefixed");
2663 }
2664
2665 #[test]
2666 fn parse_naddr_rejects_garbage_input() {
2667 assert!(parse_naddr("not an naddr").is_err());
2668 assert!(parse_naddr("naddr1invalid").is_err());
2669 assert!(parse_naddr("").is_err());
2670 }
2671
2672 #[test]
2673 fn parse_pack_address_round_trips_valid_input() {
2674 let k = keys();
2675 let hex = k.public_key().to_hex();
2676 let addr = format!("30030:{}:myId", hex);
2677 let parsed = parse_pack_address(&addr).unwrap();
2678 assert_eq!(parsed.kind, 30030);
2679 assert_eq!(parsed.pubkey, k.public_key());
2680 assert_eq!(parsed.identifier, "myId");
2681 }
2682
2683 #[test]
2684 fn parse_pack_address_rejects_wrong_kind() {
2685 let hex = keys().public_key().to_hex();
2686 let addr = format!("10030:{}:x", hex);
2687 assert!(parse_pack_address(&addr).is_err());
2688 }
2689
2690 #[test]
2691 fn parse_pack_address_rejects_malformed_pubkey() {
2692 let addr = "30030:not-hex:x".to_string();
2693 assert!(parse_pack_address(&addr).is_err());
2694 }
2695
2696 #[test]
2697 fn parse_pack_address_preserves_colons_in_identifier() {
2698 let hex = keys().public_key().to_hex();
2700 let addr = format!("30030:{}:id:with:colons", hex);
2701 let parsed = parse_pack_address(&addr).unwrap();
2702 assert_eq!(parsed.identifier, "id:with:colons");
2703 }
2704
2705 #[test]
2706 fn parse_inner_tag_list_extracts_valid_a_tags() {
2707 let hex_a = keys().public_key().to_hex();
2711 let hex_b = keys().public_key().to_hex();
2712 let plaintext = format!(
2713 r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
2714 a = hex_a,
2715 b = hex_b,
2716 );
2717 let addrs = parse_inner_tag_list(&plaintext);
2718 assert_eq!(addrs.len(), 2);
2719 assert_eq!(addrs[0].identifier, "packA");
2720 assert_eq!(addrs[1].identifier, "packB");
2721 }
2722
2723 #[test]
2724 fn parse_inner_tag_list_returns_empty_on_malformed_json() {
2725 assert!(parse_inner_tag_list("not json").is_empty());
2726 assert!(parse_inner_tag_list("").is_empty());
2727 }
2728
2729 #[test]
2730 fn theme_slot_marker_round_trips_through_publish_and_parse() {
2731 let a = keys().public_key().to_hex();
2734 let b = keys().public_key().to_hex();
2735 let c = keys().public_key().to_hex();
2736 let addr_a = format!("30030:{}:packA", a);
2737 let addr_b = format!("30030:{}:packB", b);
2738 let addr_c = format!("30030:{}:packC", c);
2739 let addrs = vec![addr_a.clone(), addr_b.clone(), addr_c.clone()];
2740
2741 let inner = build_inner_tags_with_marker(&addrs, &addr_b);
2742 let marker_count = inner.iter()
2743 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2744 .count();
2745 assert_eq!(marker_count, 1, "exactly one marker is emitted");
2746
2747 let plaintext = serde_json::to_string(&inner).unwrap();
2748 let (parsed, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
2749 assert_eq!(parsed, addrs, "addr order survives the round trip");
2750 assert_eq!(anchor.as_deref(), Some(addr_b.as_str()), "anchor is the 2nd pack");
2751
2752 let old = format!(r#"[["a","{}"],["a","{}"]]"#, addr_a, addr_b);
2754 let (old_addrs, old_anchor) = parse_inner_tag_list_with_anchor(&old);
2755 assert_eq!(old_addrs, vec![addr_a.clone(), addr_b.clone()]);
2756 assert_eq!(old_anchor, None, "a list without the marker has no anchor");
2757
2758 let top = build_inner_tags_with_marker(&addrs, "");
2760 assert_eq!(top[0], vec![THEME_SLOT_TOKEN.to_string()]);
2761 let (_, top_anchor) =
2762 parse_inner_tag_list_with_anchor(&serde_json::to_string(&top).unwrap());
2763 assert_eq!(top_anchor.as_deref(), Some(""), "top slot = empty anchor");
2764
2765 let gone = format!("30030:{}:ghost", keys().public_key().to_hex());
2767 let fallback = build_inner_tags_with_marker(&addrs, &gone);
2768 assert_eq!(fallback[0], vec![THEME_SLOT_TOKEN.to_string()]);
2769 let fb_markers = fallback.iter()
2770 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2771 .count();
2772 assert_eq!(fb_markers, 1, "still exactly one marker on the fallback path");
2773 }
2774
2775 #[test]
2776 fn shortcode_validator_accepts_alphanum_dash_underscore() {
2777 assert!(is_valid_shortcode("smile"));
2778 assert!(is_valid_shortcode("smile_face"));
2779 assert!(is_valid_shortcode("smile-face"));
2780 assert!(is_valid_shortcode("Smile2"));
2781 assert!(!is_valid_shortcode(""));
2782 assert!(!is_valid_shortcode("smile face"));
2783 assert!(!is_valid_shortcode("smile:face"));
2784 assert!(!is_valid_shortcode("😀"));
2785 assert!(!is_valid_shortcode("love~2"));
2788 }
2789
2790 #[test]
2791 fn resolve_token_disambiguates_duplicate_shortcodes() {
2792 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
2794 by_code.insert(
2795 "love".to_string(),
2796 vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
2797 );
2798 by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
2799
2800 assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
2802 assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
2804 assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
2805 assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
2807 assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
2808 assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
2809 assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
2811 assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
2812 }
2813
2814 #[test]
2815 fn message_emoji_tags_accept_disambiguation_separator() {
2816 let tags = vec![
2818 vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
2819 vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
2820 ];
2821 let out = crate::types::EmojiTag::extract_from_stored(&tags);
2822 assert_eq!(out.len(), 1);
2823 assert_eq!(out[0].shortcode, "love~2");
2824 }
2825}