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.map_or(false, |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 {
767 (status, miss_count, first_missed_at, last_miss_counted_at)
768 } else if now - last_miss_counted_at < PACK_MISS_RATELIMIT_SECS {
769 (status, miss_count, first_missed_at, last_miss_counted_at)
770 } else {
771 let first = if miss_count == 0 { now } else { first_missed_at };
772 let count = miss_count + 1;
773 let promoted = count >= PACK_MISS_PROMOTE_COUNT
774 && now - first >= PACK_MISS_PROMOTE_SECS;
775 (
776 if promoted { PACK_STATUS_MISSING as i64 } else { status },
777 count,
778 first,
779 now,
780 )
781 }
782 }
783 PackFetchOutcome::Unreachable => unreachable!(),
784 };
785
786 let changed = new_status != status;
787 tx.execute(
788 "UPDATE emoji_packs SET status = ?2, miss_count = ?3, first_missed_at = ?4,
789 last_miss_counted_at = ?5,
790 status_changed_at = CASE WHEN status != ?2 THEN ?6 ELSE status_changed_at END
791 WHERE addr = ?1",
792 rusqlite::params![addr, new_status, new_miss, new_first, new_last, now],
793 )
794 .map_err(|e| format!("write pack health: {}", e))?;
795
796 if changed && new_status != PACK_STATUS_ACTIVE as i64 {
799 tx.execute(
800 "DELETE FROM emoji_usage WHERE kind = 1 AND url IN
801 (SELECT url FROM emoji_pack_items WHERE pack_addr = ?1)",
802 rusqlite::params![addr],
803 )
804 .map_err(|e| format!("purge dead pack usage: {}", e))?;
805 }
806
807 tx.commit().map_err(|e| format!("commit pack health: {}", e))?;
808 Ok(changed)
809}
810
811pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
812 let mut conn = crate::db::get_write_connection_guard_static()?;
813 let tx = conn.transaction()
814 .map_err(|e| format!("Failed to start tx: {}", e))?;
815
816 tx.execute("DELETE FROM emoji_pack_subscriptions", [])
817 .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
818
819 let now = std::time::SystemTime::now()
820 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
821 for (pos, addr) in addrs.iter().enumerate() {
825 tx.execute(
826 "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at, position)
827 VALUES (?1, ?2, ?3)",
828 rusqlite::params![addr, now, pos as i64],
829 ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
830 }
831
832 tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
833 Ok(())
834}
835
836pub fn load_subscriptions() -> Result<Vec<String>, String> {
837 let conn = crate::db::get_db_connection_guard_static()?;
838 let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY position ASC, subscribed_at ASC")
839 .map_err(|e| format!("prepare: {}", e))?;
840 let rows = stmt.query_map([], |row| row.get::<_, String>(0))
841 .map_err(|e| format!("query: {}", e))?;
842 let mut out = Vec::new();
843 for r in rows {
844 out.push(r.map_err(|e| format!("row: {}", e))?);
845 }
846 Ok(out)
847}
848
849pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
854 let conn = crate::db::get_db_connection_guard_static()?;
855
856 let mut pack = match conn.query_row(
857 "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at, status
858 FROM emoji_packs WHERE addr = ?1",
859 rusqlite::params![addr],
860 |row| {
861 Ok(EmojiPack {
862 id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
863 pubkey: row.get(0)?,
864 identifier: row.get(1)?,
865 title: row.get(2)?,
866 image_url: row.get(3)?,
867 description: row.get(4)?,
868 is_own: row.get::<_, i32>(5)? != 0,
869 updated_at: row.get::<_, i64>(6)? as u64,
870 emojis: Vec::new(),
871 status: row.get::<_, i64>(7)? as u8,
872 })
873 },
874 ) {
875 Ok(p) => p,
876 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
877 Err(e) => return Err(format!("query cached pack: {}", e)),
878 };
879
880 let mut stmt = conn.prepare(
881 "SELECT shortcode, url, sha256 FROM emoji_pack_items
882 WHERE pack_addr = ?1 ORDER BY position ASC"
883 ).map_err(|e| format!("prepare items: {}", e))?;
884 let rows = stmt.query_map(rusqlite::params![addr], |row| {
885 Ok(PackEmoji {
886 shortcode: row.get(0)?,
887 url: row.get(1)?,
888 sha256: row.get(2)?,
889 })
890 }).map_err(|e| format!("query items: {}", e))?;
891 for r in rows {
892 pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
893 }
894
895 Ok(Some(pack))
896}
897
898pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
903 let conn = crate::db::get_db_connection_guard_static()?;
904
905 let mut packs: HashMap<String, EmojiPack> = HashMap::new();
906 let mut order: Vec<String> = Vec::new();
907
908 {
909 let mut stmt = conn.prepare(
916 "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at, p.status
917 FROM emoji_packs p
918 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
919 ORDER BY s.position ASC"
920 ).map_err(|e| format!("prepare packs: {}", e))?;
921
922 let rows = stmt.query_map([], |row| {
923 let raw_addr: String = row.get(0)?;
927 let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
928 Ok((raw_addr, EmojiPack {
929 id,
930 pubkey: row.get(1)?,
931 identifier: row.get(2)?,
932 title: row.get(3)?,
933 image_url: row.get(4)?,
934 description: row.get(5)?,
935 is_own: row.get::<_, i32>(6)? != 0,
936 updated_at: row.get::<_, i64>(7)? as u64,
937 emojis: Vec::new(),
938 status: row.get::<_, i64>(8)? as u8,
939 }))
940 }).map_err(|e| format!("query packs: {}", e))?;
941
942 for r in rows {
943 let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
944 order.push(raw_addr.clone());
945 packs.insert(raw_addr, pack);
946 }
947 }
948
949 {
950 let mut stmt = conn.prepare(
951 "SELECT pack_addr, shortcode, url, sha256
952 FROM emoji_pack_items
953 ORDER BY pack_addr, position ASC"
954 ).map_err(|e| format!("prepare items: {}", e))?;
955
956 let rows = stmt.query_map([], |row| {
957 Ok((
958 row.get::<_, String>(0)?,
959 PackEmoji {
960 shortcode: row.get(1)?,
961 url: row.get(2)?,
962 sha256: row.get(3)?,
963 },
964 ))
965 }).map_err(|e| format!("query items: {}", e))?;
966
967 for r in rows {
968 let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
969 if let Some(p) = packs.get_mut(&addr) {
970 p.emojis.push(emoji);
971 }
972 }
973 }
974
975 Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
976}
977
978const NIP65_CACHE_TTL_SECS: u64 = 3600;
987const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
990const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
991
992#[derive(Clone)]
993struct CachedRelayList {
994 relays: Vec<RelayUrl>,
995 fetched_at: std::time::Instant,
996 empty: bool,
998 verified: bool,
1002}
1003
1004struct Nip65Cache;
1007
1008fn nip65_cache() -> std::sync::Arc<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> {
1009 crate::db::current_session().scoped::<Nip65Cache, _>()
1010}
1011
1012fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1015 let owner = nip65_cache();
1016 let cache = owner.read().ok()?;
1017 let entry = cache.get(pubkey)?;
1018 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1019 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1020 Some(entry.relays.clone())
1021 } else {
1022 None
1023 }
1024}
1025
1026fn cached_write_relays_verified(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
1030 let owner = nip65_cache();
1031 let cache = owner.read().ok()?;
1032 let entry = cache.get(pubkey)?;
1033 if !entry.verified {
1034 return None;
1035 }
1036 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
1037 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
1038 Some(entry.relays.clone())
1039 } else {
1040 None
1041 }
1042}
1043
1044fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>, verified: bool) {
1046 if let Ok(mut cache) = nip65_cache().write() {
1047 let empty = relays.is_empty();
1048 cache.insert(pubkey, CachedRelayList {
1049 relays,
1050 fetched_at: std::time::Instant::now(),
1051 empty,
1052 verified,
1053 });
1054 }
1055}
1056
1057fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
1061 let mut relays: Vec<RelayUrl> = Vec::new();
1062 for (url, marker) in nostr_sdk::prelude::nip65::extract_relay_list(ev) {
1063 match marker {
1064 None | Some(nostr_sdk::prelude::nip65::RelayMetadata::Write) => {
1065 if !relays.contains(&url) {
1066 relays.push(url.clone());
1067 }
1068 }
1069 Some(nostr_sdk::prelude::nip65::RelayMetadata::Read) => {}
1070 }
1071 }
1072 relays
1073}
1074
1075async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
1080 if let Some(relays) = cached_write_relays(&pubkey) {
1081 return relays;
1082 }
1083
1084 let filter = Filter::new()
1085 .author(pubkey)
1086 .kind(Kind::RelayList)
1087 .limit(1);
1088 let events = match client
1089 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1090 .await
1091 {
1092 Ok(evs) => evs,
1093 Err(_) => {
1094 cache_write_relays(pubkey, Vec::new(), false);
1095 return Vec::new();
1096 }
1097 };
1098
1099 let relays = events.into_iter()
1100 .max_by_key(|e| e.created_at)
1101 .map(|ev| extract_write_relays(&ev))
1102 .unwrap_or_default();
1103 cache_write_relays(pubkey, relays.clone(), true);
1104 relays
1105}
1106
1107async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
1113 let uncached: Vec<PublicKey> = authors.iter()
1114 .filter(|pk| cached_write_relays(pk).is_none())
1115 .copied()
1116 .collect();
1117 if uncached.is_empty() {
1118 return;
1119 }
1120
1121 let filter = Filter::new()
1122 .authors(uncached.iter().copied())
1123 .kind(Kind::RelayList);
1124 let events = match client
1125 .fetch_events(filter).timeout(std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
1126 .await
1127 {
1128 Ok(evs) => evs,
1129 Err(_) => return,
1132 };
1133
1134 let mut newest: HashMap<PublicKey, Event> = HashMap::new();
1136 for ev in events {
1137 match newest.get(&ev.pubkey) {
1138 Some(existing) if existing.created_at >= ev.created_at => {}
1139 _ => { newest.insert(ev.pubkey, ev); }
1140 }
1141 }
1142 for pk in uncached {
1143 let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
1144 cache_write_relays(pk, relays, true);
1145 }
1146}
1147
1148async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
1157 let filter = Filter::new()
1158 .author(addr.pubkey)
1159 .kind(Kind::Custom(KIND_EMOJI_SET))
1160 .identifier(&addr.identifier)
1161 .limit(1);
1162 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1163 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1164
1165 match client.fetch_events(filter.clone()).timeout(timeout).await {
1168 Ok(events) => {
1169 if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
1170 if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
1171 return Some(pack);
1172 }
1173 }
1174 }
1175 Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
1176 }
1177
1178 let outbox = fetch_author_write_relays(client, addr.pubkey).await;
1184 if outbox.is_empty() {
1185 return None;
1186 }
1187 fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
1188}
1189
1190async fn fetch_pack_via_isolated_client(
1194 relays: &[RelayUrl],
1195 filter: Filter,
1196 timeout: std::time::Duration,
1197 my_pubkey_hex: Option<&str>,
1198) -> Option<EmojiPack> {
1199 let scratch = crate::nostr_client_builder()
1200 .build();
1201 for r in relays {
1202 let _ = scratch.add_managed_relay(r.as_str()).await;
1203 }
1204 scratch.connect().await;
1205
1206 let result = scratch.fetch_events(filter).timeout(timeout).await;
1207 scratch.shutdown().await;
1209
1210 let events = match result {
1211 Ok(events) => events,
1212 Err(e) => {
1213 crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
1214 return None;
1215 }
1216 };
1217 let event = events.into_iter().max_by_key(|e| e.created_at)?;
1218 parse_pack_from_event(&event, my_pubkey_hex)
1219}
1220
1221fn event_coord(ev: &Event) -> Option<String> {
1240 let d = first_tag(&ev.tags, &["d"])?;
1241 Some(format!("{}:{}", ev.pubkey.to_hex(), d))
1242}
1243fn addr_coord(addr: &PackAddress) -> String {
1244 format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
1245}
1246
1247fn newest_events_by_coord(
1254 events: impl IntoIterator<Item = Event>,
1255 wanted: &std::collections::HashSet<String>,
1256) -> HashMap<String, Event> {
1257 let mut newest: HashMap<String, Event> = HashMap::new();
1258 for ev in events {
1259 if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
1260 let Some(coord) = event_coord(&ev) else { continue; };
1261 if !wanted.contains(&coord) { continue; }
1262 match newest.get(&coord) {
1263 Some(existing) if existing.created_at >= ev.created_at => {}
1264 _ => { newest.insert(coord, ev); }
1265 }
1266 }
1267 newest
1268}
1269
1270fn merge_newest(acc: &mut HashMap<String, Event>, phase: HashMap<String, Event>) {
1273 for (coord, ev) in phase {
1274 match acc.get(&coord) {
1275 Some(existing) if existing.created_at >= ev.created_at => {}
1276 _ => { acc.insert(coord, ev); }
1277 }
1278 }
1279}
1280
1281fn deletion_filter(addrs: &[&PackAddress]) -> Filter {
1284 Filter::new()
1285 .authors(addrs.iter().map(|a| a.pubkey))
1286 .kind(Kind::EventDeletion)
1287 .custom_tags(
1288 SingleLetterTag::LOWERCASE_A,
1289 addrs.iter().map(|a| a.to_addr_string()),
1290 )
1291}
1292
1293fn deletion_matches(ev: &Event, author: &PublicKey, raw_addr: &str) -> bool {
1296 ev.kind == Kind::EventDeletion
1297 && ev.pubkey == *author
1298 && ev.tags.iter().any(|t| {
1299 let s = t.as_slice();
1300 s.len() >= 2 && s[0] == "a" && s[1] == raw_addr
1301 })
1302}
1303
1304async fn connected_read_relays(client: &Client, read_only: bool) -> std::collections::HashSet<String> {
1309 client
1310 .relays()
1311 .await
1312 .iter()
1313 .filter(|(_, r)| r.status() == RelayStatus::Connected && (!read_only || r.capabilities().load().can_read()))
1314 .map(|(url, _)| url.to_string())
1315 .collect()
1316}
1317
1318fn absence_is_clean(
1330 home_ok: bool,
1331 live_relays: usize,
1332 author_writes: Option<&Vec<RelayUrl>>,
1333 outbox_ok: bool,
1334 outbox_live: usize,
1335) -> bool {
1336 if !home_ok || live_relays < 2 {
1337 return false;
1338 }
1339 match author_writes {
1340 None => false,
1341 Some(w) if w.is_empty() => true,
1342 Some(_) => outbox_ok && outbox_live >= 1,
1343 }
1344}
1345
1346fn classify_pack(
1349 newest_ev: Option<&Event>,
1350 newest_deletion: Option<&Event>,
1351 me: Option<&str>,
1352 absence_clean: bool,
1353) -> (PackFetchOutcome, Option<EmojiPack>) {
1354 match newest_ev {
1355 Some(ev) => match parse_pack_from_event(ev, me) {
1356 Some(pack) => {
1357 match newest_deletion {
1360 Some(del) if del.created_at > ev.created_at => {
1361 (PackFetchOutcome::Tombstoned, None)
1362 }
1363 _ => (PackFetchOutcome::Found, Some(pack)),
1364 }
1365 }
1366 None => {
1367 let has_emoji_tags = ev.tags.iter().any(|t| {
1372 t.as_slice().first().map(|k| k == "emoji").unwrap_or(false)
1373 });
1374 if has_emoji_tags {
1375 (PackFetchOutcome::Unreachable, None)
1376 } else {
1377 (PackFetchOutcome::Tombstoned, None)
1378 }
1379 }
1380 },
1381 None if newest_deletion.is_some() => (PackFetchOutcome::Tombstoned, None),
1382 None if absence_clean => (PackFetchOutcome::CleanMiss, None),
1383 None => (PackFetchOutcome::Unreachable, None),
1384 }
1385}
1386
1387struct PackSweep {
1390 packs: Vec<EmojiPack>,
1391 outcomes: HashMap<String, PackFetchOutcome>,
1393}
1394
1395async fn sweep_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> PackSweep {
1401 if addrs.is_empty() {
1402 return PackSweep { packs: Vec::new(), outcomes: HashMap::new() };
1403 }
1404 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1405 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1406 let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1407 let all_refs: Vec<&PackAddress> = addrs.iter().collect();
1408
1409 let home_before = connected_read_relays(client, true).await;
1416 let home_filter = Filter::new()
1417 .authors(addrs.iter().map(|a| a.pubkey))
1418 .kind(Kind::Custom(KIND_EMOJI_SET))
1419 .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1420 let mut newest: HashMap<String, Event> = HashMap::new();
1421 let mut deletions: Vec<Event> = Vec::new();
1422 let mut home_ok = false;
1423 match client.fetch_events(home_filter).timeout(timeout).await {
1424 Ok(events) => {
1425 home_ok = true;
1426 merge_newest(&mut newest, newest_events_by_coord(events, &wanted));
1427 }
1428 Err(e) => {
1429 crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1430 }
1431 }
1432 if home_ok {
1433 match client.fetch_events(deletion_filter(&all_refs)).timeout(timeout).await {
1434 Ok(events) => deletions.extend(events),
1435 Err(e) => crate::log_warn!("[EmojiPacks] home deletion fetch failed: {}", e),
1436 }
1437 }
1438 let home_after = connected_read_relays(client, true).await;
1439 let mut live_relays: std::collections::HashSet<String> =
1440 home_before.intersection(&home_after).cloned().collect();
1441
1442 let misses: Vec<&PackAddress> = addrs.iter()
1446 .filter(|a| !newest.contains_key(&addr_coord(a)))
1447 .collect();
1448 let mut outbox_ok = false;
1449 let mut outbox_live = 0usize;
1450 if !misses.is_empty() {
1451 let miss_authors: Vec<PublicKey> = {
1452 let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1453 v.sort(); v.dedup();
1454 v
1455 };
1456 prefetch_author_write_relays(client, &miss_authors).await;
1459 let mut outbox: Vec<RelayUrl> = Vec::new();
1460 for pk in &miss_authors {
1461 for r in cached_write_relays(pk).unwrap_or_default() {
1462 if !outbox.contains(&r) { outbox.push(r); }
1463 }
1464 }
1465 if !outbox.is_empty() {
1466 let miss_filter = Filter::new()
1467 .authors(misses.iter().map(|a| a.pubkey))
1468 .kind(Kind::Custom(KIND_EMOJI_SET))
1469 .identifiers(misses.iter().map(|a| a.identifier.clone()));
1470 let wanted_misses: std::collections::HashSet<String> =
1471 misses.iter().map(|a| addr_coord(a)).collect();
1472 if let Some((events, dels, live)) = sweep_via_isolated_client(
1473 &outbox, miss_filter, deletion_filter(&misses), timeout,
1474 ).await {
1475 outbox_ok = true;
1476 outbox_live = live.len();
1477 live_relays.extend(live);
1478 merge_newest(&mut newest, newest_events_by_coord(events, &wanted_misses));
1479 deletions.extend(dels);
1480 }
1481 }
1482 }
1483
1484 let mut packs: Vec<EmojiPack> = Vec::new();
1486 let mut outcomes: HashMap<String, PackFetchOutcome> = HashMap::new();
1487 for addr in addrs {
1488 let coord = addr_coord(addr);
1489 let raw_addr = addr.to_addr_string();
1490 let newest_deletion = deletions.iter()
1491 .filter(|d| deletion_matches(d, &addr.pubkey, &raw_addr))
1492 .max_by_key(|d| d.created_at);
1493 let author_writes = cached_write_relays_verified(&addr.pubkey);
1494 let clean = absence_is_clean(
1495 home_ok,
1496 live_relays.len(),
1497 author_writes.as_ref(),
1498 outbox_ok,
1499 outbox_live,
1500 );
1501 let (outcome, pack) = classify_pack(
1502 newest.get(&coord),
1503 newest_deletion,
1504 me.as_deref(),
1505 clean,
1506 );
1507 if let Some(p) = pack {
1508 packs.push(p);
1509 }
1510 outcomes.insert(raw_addr, outcome);
1511 }
1512
1513 PackSweep { packs, outcomes }
1514}
1515
1516async fn sweep_via_isolated_client(
1523 relays: &[RelayUrl],
1524 pack_filter: Filter,
1525 del_filter: Filter,
1526 timeout: std::time::Duration,
1527) -> Option<(Vec<Event>, Vec<Event>, std::collections::HashSet<String>)> {
1528 let scratch = crate::nostr_client_builder()
1529 .build();
1530 for r in relays {
1531 let _ = scratch.add_managed_relay(r.as_str()).await;
1532 }
1533 scratch.connect().await;
1534 let connect_deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
1539 let before = loop {
1540 let connected = connected_read_relays(&scratch, false).await;
1541 if !connected.is_empty() || std::time::Instant::now() >= connect_deadline {
1542 break connected;
1543 }
1544 tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1545 };
1546 let result = scratch.fetch_events(pack_filter).timeout(timeout).await;
1547 let dels = match &result {
1548 Ok(_) => scratch.fetch_events(del_filter).timeout(timeout).await.ok(),
1549 Err(_) => None,
1550 };
1551 let after = connected_read_relays(&scratch, false).await;
1552 scratch.shutdown().await;
1553
1554 match result {
1555 Ok(events) => Some((
1556 events.into_iter().collect(),
1557 dels.map(|d| d.into_iter().collect()).unwrap_or_default(),
1558 before.intersection(&after).cloned().collect(),
1559 )),
1560 Err(e) => {
1561 crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1562 None
1563 }
1564 }
1565}
1566
1567pub async fn fetch_subscribed_packs(
1576 client: &Client,
1577 my_pubkey: PublicKey,
1578) -> Result<Vec<EmojiPack>, String> {
1579 crate::db::scoped(async move {
1580 let list_filter = Filter::new()
1581 .author(my_pubkey)
1582 .kind(Kind::Custom(KIND_EMOJI_LIST))
1583 .limit(1);
1584
1585 let list_events = client
1586 .fetch_events(list_filter).timeout(std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1587 .await
1588 .map_err(|e| format!("fetch kind 10030: {}", e))?;
1589
1590
1591 let local_addrs = || -> Vec<PackAddress> {
1599 load_subscriptions()
1600 .unwrap_or_default()
1601 .into_iter()
1602 .filter_map(|s| parse_pack_address(&s).ok())
1603 .collect()
1604 };
1605 let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1606 .ok()
1607 .flatten()
1608 .and_then(|s| s.parse().ok())
1609 .unwrap_or(0);
1610
1611 let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1612 let mut fetched_anchor: Option<String> = None;
1615 let addrs: Vec<PackAddress> = match list_event {
1616 Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1617 crate::log_debug!(
1618 "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1619 ev.created_at.as_secs(), our_last_publish,
1620 );
1621 local_addrs()
1622 }
1623 Some(ev) => {
1624 let (a, anchor) = decrypt_subscribed_addresses_with_anchor(client, &my_pubkey, &ev).await;
1625 fetched_anchor = anchor;
1626 a
1627 }
1628 None => {
1629 crate::log_debug!(
1630 "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1631 );
1632 local_addrs()
1633 }
1634 };
1635
1636 let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1637
1638 let PackSweep { packs: fresh, outcomes } = sweep_packs_from_relays(client, &addrs).await;
1645 let tombstoned = outcomes.values().filter(|o| matches!(o, PackFetchOutcome::Tombstoned)).count();
1646 let unresolved = addrs.len().saturating_sub(fresh.len()).saturating_sub(tombstoned);
1647 if unresolved > 0 {
1648 crate::log_warn!(
1649 "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1650 unresolved,
1651 );
1652 }
1653 if tombstoned > 0 {
1654 crate::log_info!("[EmojiPacks] {} subscribed pack(s) deleted by their creator", tombstoned);
1655 }
1656
1657
1658 let me_hex = my_pubkey.to_hex();
1664 let now = std::time::SystemTime::now()
1665 .duration_since(std::time::UNIX_EPOCH)
1666 .map(|d| d.as_secs() as i64)
1667 .unwrap_or(0);
1668 let mut health_changed = false;
1669 for addr in &addrs {
1670 if addr.pubkey.to_hex() == me_hex { continue; }
1671 let raw = addr.to_addr_string();
1672 let Some(outcome) = outcomes.get(&raw) else { continue; };
1673 match apply_pack_health(&raw, outcome, now) {
1674 Ok(true) => {
1675 health_changed = true;
1676 crate::log_info!(
1677 "[EmojiPacks] pack `{}` health changed ({:?})",
1678 addr.identifier, outcome,
1679 );
1680 }
1681 Ok(false) => {}
1682 Err(e) => crate::log_warn!("[EmojiPacks] health update for `{}` failed: {}", addr.identifier, e),
1683 }
1684 }
1685 for pack in &fresh {
1686 if let Err(e) = save_pack(pack) {
1687 crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1688 }
1689 }
1690 let list_changed = load_subscriptions().unwrap_or_default() != addr_strings
1695 || fetched_anchor.as_deref().map_or(false, |a| a != load_theme_slot_anchor());
1696
1697 if let Err(e) = save_subscriptions(&addr_strings) {
1701 crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1702 }
1703 if let Some(anchor) = fetched_anchor {
1706 if let Err(e) = save_theme_slot_anchor(&anchor) {
1707 crate::log_warn!("[EmojiPacks] save theme slot anchor failed: {}", e);
1708 }
1709 }
1710 if health_changed || list_changed {
1711 crate::traits::emit_event("emoji_packs_updated", &());
1712 }
1713
1714 crate::log_info!(
1715 "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1716 fresh.len(),
1717 addrs.len(),
1718 if unresolved > 0 {
1719 format!(" ({} via cache)", unresolved)
1720 } else {
1721 String::new()
1722 },
1723 );
1724
1725 load_all_packs()
1728 })
1729 .await
1730}
1731
1732pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1737 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1738 let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1739 fetch_subscribed_packs(&client, me).await
1740}
1741
1742pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1746 let addr = parse_naddr(naddr)?;
1747 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1748 fetch_pack_from_relays(&client, &addr).await
1749 .ok_or_else(|| {
1750 crate::log_debug!("[EmojiPacks] preview miss: {}:{}", addr.pubkey.to_hex(), addr.identifier);
1753 "Pack not found on any relay".to_string()
1754 })
1755}
1756
1757pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1765 crate::db::scoped(async move {
1766 let addr = parse_naddr(naddr)?;
1767 let coord = addr.to_addr_string();
1768
1769 if let Some(cached) = load_cached_pack(&coord)? {
1772 let naddr_owned = naddr.to_string();
1773 crate::db::spawn_bound(async move {
1774 let Some(client) = nostr_client() else { return };
1775 if let Ok(parsed) = parse_naddr(&naddr_owned) {
1776 if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1777 if fresh.updated_at > cached.updated_at {
1778 if let Err(e) = save_pack(&fresh) {
1779 crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1780 } else {
1781 crate::traits::emit_event("emoji_packs_updated", &());
1782 }
1783 }
1784 }
1785 }
1786 });
1787 return Ok(Some(cached));
1788 }
1789
1790 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1792 match fetch_pack_from_relays(&client, &addr).await {
1793 Some(pack) => {
1794 if let Err(e) = save_pack(&pack) {
1798 crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1799 }
1800 Ok(Some(pack))
1801 }
1802 None => Ok(None),
1803 }
1804 })
1805 .await
1806}
1807
1808const THEME_SLOT_ANCHOR_KEY: &str = "emoji_theme_slot_anchor";
1814
1815fn load_theme_slot_anchor() -> String {
1817 crate::db::settings::get_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string())
1818 .ok()
1819 .flatten()
1820 .unwrap_or_default()
1821}
1822
1823fn save_theme_slot_anchor(raw_addr: &str) -> Result<(), String> {
1825 crate::db::settings::set_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string(), raw_addr.to_string())
1826}
1827
1828fn build_inner_tags_with_marker(addrs: &[String], anchor: &str) -> Vec<Vec<String>> {
1833 let mut inner: Vec<Vec<String>> = Vec::with_capacity(addrs.len() + 1);
1834 let mut placed = false;
1835 if anchor.is_empty() {
1836 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1837 placed = true;
1838 }
1839 for addr in addrs {
1840 inner.push(vec!["a".to_string(), addr.clone()]);
1841 if !placed && addr == anchor {
1842 inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
1843 placed = true;
1844 }
1845 }
1846 if !placed {
1849 inner.insert(0, vec![THEME_SLOT_TOKEN.to_string()]);
1850 }
1851 inner
1852}
1853
1854pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1865 let addrs = load_subscriptions()?;
1866 let anchor = load_theme_slot_anchor();
1867 let my_pk = crate::state::my_public_key()
1868 .ok_or_else(|| "Not logged in".to_string())?;
1869
1870 let inner_tags = build_inner_tags_with_marker(&addrs, &anchor);
1871 let plaintext = serde_json::to_string(&inner_tags)
1872 .map_err(|e| format!("Serialise emoji list: {}", e))?;
1873
1874 let signer = crate::signer::active_signer()
1875 .map_err(|e| format!("Signer unavailable: {}", e))?;
1876 let content = signer.nip44_encrypt_async(&my_pk, &plaintext).await
1877 .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1878
1879 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1880 crate::sign_and_send(&client, builder).await
1881 .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1882
1883 crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1884 Ok(())
1885}
1886
1887const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1891
1892static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1893
1894pub fn republish_emoji_list_debounced() {
1898 use std::sync::atomic::Ordering;
1899 let _ = crate::db::settings::set_sql_setting(
1905 EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1906 Timestamp::now().as_secs().to_string(),
1907 );
1908 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1909 crate::db::spawn_bound(async move {
1910 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1911 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1912 let client = match nostr_client() {
1913 Some(c) => c,
1914 None => return,
1915 };
1916 if let Err(e) = publish_emoji_list(&client).await {
1917 crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1918 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1919 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1920 if let Err(e2) = publish_emoji_list(&client).await {
1921 crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1922 }
1923 }
1924 });
1925}
1926
1927pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1931 crate::db::scoped(async move {
1932 let pack = fetch_pack_by_naddr(naddr).await?;
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 save_subscriptions(&subs)?;
1956
1957 republish_emoji_list_debounced();
1958 crate::traits::emit_event("emoji_packs_updated", &());
1959
1960 Ok(pack)
1961 })
1962 .await
1963}
1964
1965fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1974 if pack.identifier.is_empty() {
1975 return Err("pack identifier required".to_string());
1976 }
1977 let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1978 .tag(Tag::custom("d", [pack.identifier.clone()]))
1979 .tag(Tag::custom("client", ["vector"]));
1982
1983 if !pack.title.is_empty() {
1985 builder = builder
1986 .tag(Tag::custom("title", [pack.title.clone()]))
1987 .tag(Tag::custom("name", [pack.title.clone()]));
1988 }
1989 if !pack.image_url.is_empty() {
1990 builder = builder
1991 .tag(Tag::custom("image", [pack.image_url.clone()]))
1992 .tag(Tag::custom("picture", [pack.image_url.clone()]));
1993 }
1994 if !pack.description.is_empty() {
1995 builder = builder
1996 .tag(Tag::custom("description", [pack.description.clone()]))
1997 .tag(Tag::custom("about", [pack.description.clone()]));
1998 }
1999
2000 for e in &pack.emojis {
2001 if e.shortcode.is_empty() || e.url.is_empty() { continue; }
2002 builder = builder.tag(Tag::custom(
2003 "emoji",
2004 [e.shortcode.clone(), e.url.clone()],
2005 ));
2006 }
2007 Ok(builder)
2008}
2009
2010pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
2015 crate::db::scoped(async move {
2016 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2017 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2018
2019 let emoji_cap = effective_max_emojis_per_pack();
2022 if pack.emojis.len() > emoji_cap {
2023 return Err(format!(
2024 "A pack can hold at most {} emojis.",
2025 emoji_cap,
2026 ));
2027 }
2028
2029 let mut to_save = pack.clone();
2032 to_save.pubkey = my_pk.to_hex();
2033 to_save.is_own = true;
2034 let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
2035 to_save.id = naddr_from_addr(&raw_addr)?;
2036 to_save.updated_at = std::time::SystemTime::now()
2037 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
2038
2039 {
2042 let existing_subs = load_subscriptions()?;
2043 let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
2044 let cap = effective_max_equipped_packs();
2045 if is_new && existing_subs.len() >= cap {
2046 return Err(format!(
2047 "You can equip at most {} packs. Remove one to add another.",
2048 cap,
2049 ));
2050 }
2051 }
2052
2053 let builder = build_pack_event(&to_save)?;
2054 crate::sign_and_send(&client, builder).await
2055 .map_err(|e| format!("publish kind 30030: {}", e))?;
2056
2057
2058 save_pack(&to_save)?;
2059
2060 let mut subs = load_subscriptions()?;
2063 if !subs.iter().any(|a| a == &raw_addr) {
2064 subs.push(raw_addr.clone());
2065 save_subscriptions(&subs)?;
2066 republish_emoji_list_debounced();
2067 }
2068
2069 crate::traits::emit_event("emoji_packs_updated", &());
2070 crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
2071 to_save.identifier, to_save.emojis.len());
2072
2073 Ok(to_save)
2074 })
2075 .await
2076}
2077
2078pub async fn delete_own_pack(id: &str) -> Result<(), String> {
2082 crate::db::scoped(async move {
2083 let parsed = parse_naddr(id)?;
2084 let raw_addr = parsed.to_addr_string();
2085 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
2086 if parsed.pubkey != my_pk {
2087 return Err("Cannot delete a pack you don't own".to_string());
2088 }
2089 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
2090
2091 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2092 .tag(Tag::custom("d", [parsed.identifier.clone()]));
2093 crate::sign_and_send(&client, builder).await
2094 .map_err(|e| format!("publish empty kind 30030: {}", e))?;
2095
2096
2097 {
2101 let mut conn = crate::db::get_write_connection_guard_static()?;
2102 let tx = conn.transaction()
2103 .map_err(|e| format!("begin delete tx: {}", e))?;
2104 tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
2105 rusqlite::params![raw_addr])
2106 .map_err(|e| format!("drop subscription: {}", e))?;
2107 tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
2108 rusqlite::params![raw_addr])
2109 .map_err(|e| format!("drop pack row: {}", e))?;
2110 tx.commit()
2111 .map_err(|e| format!("commit delete tx: {}", e))?;
2112 }
2113
2114 republish_emoji_list_debounced();
2115 crate::traits::emit_event("emoji_packs_updated", &());
2116 crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
2117 Ok(())
2118 })
2119 .await
2120}
2121
2122pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
2126 crate::db::scoped(async move {
2127 let raw_addr = parse_naddr(id)?.to_addr_string();
2128 let mut subs = load_subscriptions()?;
2129 let before = subs.len();
2130 subs.retain(|a| a != &raw_addr);
2131 if subs.len() == before {
2132 return Ok(()); }
2134 save_subscriptions(&subs)?;
2135 republish_emoji_list_debounced();
2136 crate::traits::emit_event("emoji_packs_updated", &());
2137 Ok(())
2138 })
2139 .await
2140}
2141
2142pub fn reorder_emoji_packs(ordered_ids: Vec<String>) -> Result<(), String> {
2151 let session = crate::db::current_session();
2152
2153 let mut real_addrs: Vec<String> = Vec::with_capacity(ordered_ids.len());
2154 let mut anchor = String::new();
2155 let mut marker_present = false;
2156 for id in &ordered_ids {
2157 if id == THEME_SLOT_TOKEN {
2158 marker_present = true;
2159 anchor = real_addrs.last().cloned().unwrap_or_default();
2160 } else {
2161 real_addrs.push(parse_naddr(id)?.to_addr_string());
2162 }
2163 }
2164
2165 if !session.is_live() {
2166 return Err("Account swapped during reorder — aborted".to_string());
2167 }
2168 save_subscriptions(&real_addrs)?;
2169 if marker_present {
2173 save_theme_slot_anchor(&anchor)?;
2174 }
2175 republish_emoji_list_debounced();
2176 crate::traits::emit_event("emoji_packs_updated", &());
2177 Ok(())
2178}
2179
2180pub fn get_theme_slot_anchor() -> Result<String, String> {
2184 let raw = load_theme_slot_anchor();
2185 if raw.is_empty() {
2186 return Ok(String::new());
2187 }
2188 Ok(naddr_from_addr(&raw).unwrap_or_default())
2189}
2190
2191#[cfg(test)]
2196mod tests {
2197 use super::*;
2198
2199 fn keys() -> Keys {
2200 Keys::generate()
2201 }
2202
2203 fn build_pack_event(
2204 k: &Keys,
2205 d: &str,
2206 title_tag: Option<(&str, &str)>,
2207 image_tag: Option<(&str, &str)>,
2208 desc_tag: Option<(&str, &str)>,
2209 emojis: &[(&str, &str)],
2210 ) -> Event {
2211 let mut tags: Vec<Tag> = Vec::new();
2212 tags.push(Tag::custom("d", [d]));
2213 if let Some((key, val)) = title_tag {
2214 tags.push(Tag::custom(key, [val]));
2215 }
2216 if let Some((key, val)) = image_tag {
2217 tags.push(Tag::custom(key, [val]));
2218 }
2219 if let Some((key, val)) = desc_tag {
2220 tags.push(Tag::custom(key, [val]));
2221 }
2222 for (code, url) in emojis {
2223 tags.push(Tag::custom("emoji", [*code, *url]));
2224 }
2225 EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2226 .tags(tags)
2227 .finalize(k)
2228 .unwrap()
2229 }
2230
2231 fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
2232 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
2233 crate::db::close_database();
2234 crate::db::clear_id_caches();
2237 let tmp = tempfile::tempdir().unwrap();
2238 let account = keys().public_key().to_bech32().unwrap();
2239 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
2240 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
2241 crate::db::set_current_account(account.clone()).unwrap();
2242 crate::db::init_database(&account).unwrap();
2243 (tmp, guard)
2244 }
2245
2246 fn seed_pack(k: &Keys, d: &str, emojis: &[(&str, &str)]) -> String {
2248 let ev = build_pack_event(k, d, None, None, None, emojis);
2249 let pack = parse_pack_from_event(&ev, None).unwrap();
2250 save_pack(&pack).unwrap();
2251 pack.addr()
2252 }
2253
2254 fn seed_usage(url: &str) {
2255 let conn = crate::db::get_write_connection_guard_static().unwrap();
2256 conn.execute(
2257 "INSERT INTO emoji_usage (kind, id, url, score, last_used) VALUES (1, ?1, ?1, 1.0, 0)",
2258 rusqlite::params![url],
2259 ).unwrap();
2260 }
2261
2262 fn usage_count() -> i64 {
2263 let conn = crate::db::get_db_connection_guard_static().unwrap();
2264 conn.query_row("SELECT COUNT(*) FROM emoji_usage", [], |r| r.get(0)).unwrap()
2265 }
2266
2267 fn pack_status(addr: &str) -> u8 {
2268 load_cached_pack(addr).unwrap().unwrap().status
2269 }
2270
2271 fn build_deletion_event(k: &Keys, addr: &str) -> Event {
2272 EventBuilder::new(Kind::EventDeletion, "")
2273 .tag(Tag::custom("a", [addr]))
2274 .finalize(k)
2275 .unwrap()
2276 }
2277
2278 #[test]
2279 fn classify_verdict_table() {
2280 let k = keys();
2281 let live = build_pack_event(&k, "p", None, None, None, &[("a", "https://e.x/a.png")]);
2282 let empty = build_pack_event(&k, "p", None, None, None, &[]);
2283 let addr = format!("30030:{}:p", k.public_key().to_hex());
2284 let del = build_deletion_event(&k, &addr);
2285
2286 let (o, p) = classify_pack(Some(&live), None, None, false);
2288 assert_eq!(o, PackFetchOutcome::Found);
2289 assert!(p.is_some());
2290
2291 let (o, _) = classify_pack(Some(&empty), None, None, true);
2293 assert_eq!(o, PackFetchOutcome::Tombstoned);
2294
2295 let (o, _) = classify_pack(None, Some(&del), None, true);
2297 assert_eq!(o, PackFetchOutcome::Tombstoned);
2298
2299 let ts = Timestamp::from_secs(1_700_000_000);
2302 let live_pinned = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2303 .tags(vec![
2304 Tag::custom("d", ["p"]),
2305 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2306 ])
2307 .custom_created_at(ts)
2308 .finalize(&k)
2309 .unwrap();
2310 let del_at = |secs: u64| {
2311 EventBuilder::new(Kind::EventDeletion, "")
2312 .tag(Tag::custom("a", [addr.as_str()]))
2313 .custom_created_at(Timestamp::from_secs(secs))
2314 .finalize(&k)
2315 .unwrap()
2316 };
2317
2318 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_000)), None, false);
2321 assert_eq!(o, PackFetchOutcome::Found);
2322 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_001)), None, false);
2324 assert_eq!(o, PackFetchOutcome::Tombstoned);
2325 let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_699_999_999)), None, false);
2327 assert_eq!(o, PackFetchOutcome::Found);
2328
2329 let (o, _) = classify_pack(None, None, None, true);
2331 assert_eq!(o, PackFetchOutcome::CleanMiss);
2332 let (o, _) = classify_pack(None, None, None, false);
2333 assert_eq!(o, PackFetchOutcome::Unreachable);
2334 }
2335
2336 #[test]
2337 fn classify_invalid_emojis_is_not_a_tombstone() {
2338 let k = keys();
2341 let ev = build_pack_event(&k, "weird", None, None, None, &[("bad!code", "https://e.x/a.png")]);
2342 assert!(parse_pack_from_event(&ev, None).is_none(), "precondition: unparseable");
2343 let (o, _) = classify_pack(Some(&ev), None, None, true);
2344 assert_eq!(o, PackFetchOutcome::Unreachable);
2345 }
2346
2347 #[test]
2348 fn absence_gate_table() {
2349 let some_writes = vec![RelayUrl::parse("wss://relay.author.example").unwrap()];
2350 let no_writes: Vec<RelayUrl> = Vec::new();
2351
2352 assert!(!absence_is_clean(false, 5, Some(&no_writes), true, 1));
2354 assert!(!absence_is_clean(true, 1, Some(&no_writes), true, 1));
2355 assert!(!absence_is_clean(true, 3, None, true, 1));
2358 assert!(absence_is_clean(true, 2, Some(&no_writes), false, 0));
2360 assert!(!absence_is_clean(true, 3, Some(&some_writes), false, 0));
2362 assert!(!absence_is_clean(true, 3, Some(&some_writes), true, 0));
2363 assert!(absence_is_clean(true, 3, Some(&some_writes), true, 1));
2364 }
2365
2366 #[test]
2367 fn deletion_matching_is_author_bound() {
2368 let author = keys();
2369 let stranger = keys();
2370 let addr = format!("30030:{}:p", author.public_key().to_hex());
2371 let forged = build_deletion_event(&stranger, &addr);
2373 assert!(!deletion_matches(&forged, &author.public_key(), &addr));
2374 let real = build_deletion_event(&author, &addr);
2375 assert!(deletion_matches(&real, &author.public_key(), &addr));
2376 }
2377
2378 #[test]
2379 fn tombstone_revokes_immediately_and_purges_frecency() {
2380 let (_tmp, _guard) = init_test_db();
2381 let k = keys();
2382 let addr = seed_pack(&k, "deadpack", &[("boom", "https://e.x/boom.png")]);
2383 seed_usage("https://e.x/boom.png");
2384 seed_usage("https://e.x/unrelated.png");
2385
2386 let changed = apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2387 assert!(changed, "tombstone must flip status in one sweep");
2388 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2389 assert_eq!(usage_count(), 1, "only the dead pack's usage rows purge");
2390 }
2391
2392 #[test]
2393 fn found_self_heals_from_revoked() {
2394 let (_tmp, _guard) = init_test_db();
2395 let k = keys();
2396 let addr = seed_pack(&k, "phoenix", &[("rise", "https://e.x/r.png")]);
2397 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2398 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
2399
2400 let changed = apply_pack_health(&addr, &PackFetchOutcome::Found, 2_000).unwrap();
2401 assert!(changed);
2402 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "a republished pack revives");
2403 }
2404
2405 #[test]
2406 fn clean_miss_gauntlet_rate_limit_and_promotion() {
2407 let (_tmp, _guard) = init_test_db();
2408 let k = keys();
2409 let addr = seed_pack(&k, "fading", &[("bye", "https://e.x/bye.png")]);
2410 let t0: i64 = 1_000_000;
2411 let h = 3_600;
2412
2413 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0).unwrap());
2415 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + h).unwrap());
2416 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 13 * h).unwrap());
2418 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 26 * h).unwrap());
2419 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "count alone must not promote");
2420 let changed = apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 49 * h).unwrap();
2422 assert!(changed);
2423 assert_eq!(pack_status(&addr), PACK_STATUS_MISSING);
2424 }
2425
2426 #[test]
2427 fn unreachable_never_moves_counters() {
2428 let (_tmp, _guard) = init_test_db();
2429 let k = keys();
2430 let addr = seed_pack(&k, "offline", &[("zz", "https://e.x/z.png")]);
2431 for t in [1_000i64, 200_000, 400_000, 800_000] {
2432 assert!(!apply_pack_health(&addr, &PackFetchOutcome::Unreachable, t).unwrap());
2433 }
2434 assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE);
2435 let conn = crate::db::get_db_connection_guard_static().unwrap();
2436 let miss: i64 = conn.query_row(
2437 "SELECT miss_count FROM emoji_packs WHERE addr = ?1",
2438 rusqlite::params![addr], |r| r.get(0),
2439 ).unwrap();
2440 assert_eq!(miss, 0, "offline sweeps must not accrue misses");
2441 }
2442
2443 #[test]
2444 fn revoked_ignores_clean_misses() {
2445 let (_tmp, _guard) = init_test_db();
2446 let k = keys();
2447 let addr = seed_pack(&k, "sealed", &[("x", "https://e.x/x.png")]);
2448 apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
2449 for t in [100_000i64, 300_000, 600_000] {
2450 assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t).unwrap());
2451 }
2452 assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED, "misses can't downgrade a tombstone verdict");
2453 }
2454
2455 #[test]
2456 fn empty_pack_event_is_the_tombstone_shape() {
2457 let k = keys();
2461 let ev = build_pack_event(&k, "gone", None, None, None, &[]);
2462 assert!(parse_pack_from_event(&ev, None).is_none());
2463 }
2464
2465 #[test]
2466 fn parse_pack_reads_nip51_spec_tags() {
2467 let k = keys();
2468 let ev = build_pack_event(
2469 &k, "myPack",
2470 Some(("title", "Spec Pack")),
2471 Some(("image", "https://example.com/p.png")),
2472 Some(("description", "specd")),
2473 &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
2474 );
2475 let pack = parse_pack_from_event(&ev, None).unwrap();
2476 assert_eq!(pack.identifier, "myPack");
2477 assert_eq!(pack.title, "Spec Pack");
2478 assert_eq!(pack.image_url, "https://example.com/p.png");
2479 assert_eq!(pack.description, "specd");
2480 assert_eq!(pack.emojis.len(), 2);
2481 assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
2482 }
2483
2484 #[test]
2485 fn own_pack_event_carries_the_vector_client_tag() {
2486 let k = keys();
2489 let ev = build_pack_event(&k, "myPack", Some(("title", "Mine")), None, None, &[("smile", "https://e.x/s.png")]);
2490 let pack = parse_pack_from_event(&ev, None).unwrap();
2491 let built = super::build_pack_event(&pack).unwrap().finalize(&k).unwrap();
2492 let has_client = built.tags.iter().any(|t| {
2493 let s = t.as_slice();
2494 s.first().map(String::as_str) == Some("client") && s.get(1).map(String::as_str) == Some("vector")
2495 });
2496 assert!(has_client, "own pack event must carry the [\"client\",\"vector\"] tag");
2497 }
2498
2499 #[test]
2500 fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
2501 let k = keys();
2502 let ev = build_pack_event(
2503 &k, "ditto",
2504 Some(("name", "Ditto Pack")),
2505 Some(("picture", "https://example.com/d.png")),
2506 Some(("about", "ditto-style")),
2507 &[("yes", "https://e.x/y.png")],
2508 );
2509 let pack = parse_pack_from_event(&ev, None).unwrap();
2510 assert_eq!(pack.title, "Ditto Pack");
2511 assert_eq!(pack.image_url, "https://example.com/d.png");
2512 assert_eq!(pack.description, "ditto-style");
2513 }
2514
2515 #[test]
2516 fn parse_pack_prefers_spec_tags_over_ditto() {
2517 let k = keys();
2518 let mut tags: Vec<Tag> = vec![
2519 Tag::custom("d", ["both"]),
2520 Tag::custom("title", ["SpecTitle"]),
2521 Tag::custom("name", ["DittoName"]),
2522 Tag::custom("image", ["spec.png"]),
2523 Tag::custom("picture", ["ditto.png"]),
2524 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2525 ];
2526 tags.extend(std::iter::empty());
2527 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2528 .tags(tags).finalize(&k).unwrap();
2529 let pack = parse_pack_from_event(&ev, None).unwrap();
2530 assert_eq!(pack.title, "SpecTitle");
2531 assert_eq!(pack.image_url, "spec.png");
2532 }
2533
2534 #[test]
2535 fn parse_pack_returns_none_without_d_tag() {
2536 let k = keys();
2537 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
2538 .tags(vec![
2539 Tag::custom("title", ["No D"]),
2540 Tag::custom("emoji", ["a", "https://e.x/a.png"]),
2541 ])
2542 .finalize(&k).unwrap();
2543 assert!(parse_pack_from_event(&ev, None).is_none());
2544 }
2545
2546 #[test]
2547 fn parse_pack_returns_none_when_no_valid_emojis() {
2548 let k = keys();
2549 let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
2550 assert!(parse_pack_from_event(&ev, None).is_none());
2551 }
2552
2553 #[test]
2554 fn parse_pack_rejects_invalid_shortcodes() {
2555 let k = keys();
2556 let ev = build_pack_event(
2557 &k, "mix", Some(("title", "Mix")), None, None,
2558 &[("ok_name", "https://e.x/a.png"),
2559 ("bad name", "https://e.x/b.png"),
2560 ("colons:no", "https://e.x/c.png"),
2561 ("", "https://e.x/d.png"),
2562 ("dash-ok", "https://e.x/e.png")],
2563 );
2564 let pack = parse_pack_from_event(&ev, None).unwrap();
2565 let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
2566 assert_eq!(codes, vec!["ok_name", "dash-ok"]);
2567 }
2568
2569 #[test]
2570 fn parse_pack_dedupes_shortcodes_first_wins() {
2571 let k = keys();
2572 let ev = build_pack_event(
2573 &k, "dup", Some(("title", "Dup")), None, None,
2574 &[("smile", "https://e.x/first.png"),
2575 ("smile", "https://e.x/second.png")],
2576 );
2577 let pack = parse_pack_from_event(&ev, None).unwrap();
2578 assert_eq!(pack.emojis.len(), 1);
2579 assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
2580 }
2581
2582 #[test]
2583 fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
2584 let k = keys();
2585 let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
2586 &[("a", "https://e.x/a.png")]);
2587 let my_hex = k.public_key().to_hex();
2588 let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
2589 assert!(pack_mine.is_own);
2590
2591 let stranger = keys().public_key().to_hex();
2592 let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
2593 assert!(!pack_other.is_own);
2594 }
2595
2596 #[test]
2597 fn pack_address_to_string_round_trips() {
2598 let k = keys();
2599 let hex = k.public_key().to_hex();
2600 let addr = PackAddress {
2601 kind: 30030,
2602 pubkey: k.public_key(),
2603 identifier: "myPack".to_string(),
2604 };
2605 assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
2606 let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
2607 assert_eq!(parsed, addr);
2608 }
2609
2610 #[test]
2611 fn parse_naddr_round_trips_kind_30030_coordinate() {
2612 let k = keys();
2615 let coord = nostr_sdk::prelude::nip01::Coordinate {
2616 kind: Kind::Custom(30030),
2617 public_key: k.public_key(),
2618 identifier: "trip".to_string(),
2619 };
2620 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2621 coordinate: coord,
2622 relays: Vec::new(),
2623 };
2624 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2625 let parsed = parse_naddr(&naddr).unwrap();
2626 assert_eq!(parsed.kind, 30030);
2627 assert_eq!(parsed.pubkey, k.public_key());
2628 assert_eq!(parsed.identifier, "trip");
2629 }
2630
2631 #[test]
2632 fn parse_naddr_rejects_non_30030_kinds() {
2633 let k = keys();
2634 let coord = nostr_sdk::prelude::nip01::Coordinate {
2635 kind: Kind::Custom(30023), public_key: k.public_key(),
2637 identifier: "essay".to_string(),
2638 };
2639 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2640 coordinate: coord,
2641 relays: Vec::new(),
2642 };
2643 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2644 let err = parse_naddr(&naddr).unwrap_err();
2645 assert!(err.contains("expected kind 30030"),
2646 "expected kind-rejection error, got: {}", err);
2647 }
2648
2649 #[test]
2650 fn parse_naddr_strips_nostr_uri_prefix() {
2651 let k = keys();
2652 let coord = nostr_sdk::prelude::nip01::Coordinate {
2653 kind: Kind::Custom(30030),
2654 public_key: k.public_key(),
2655 identifier: "prefixed".to_string(),
2656 };
2657 let n19 = nostr_sdk::prelude::nip19::Nip19Coordinate {
2658 coordinate: coord,
2659 relays: Vec::new(),
2660 };
2661 let naddr = nostr_sdk::prelude::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
2662 let with_prefix = format!("nostr:{}", naddr);
2663 let parsed = parse_naddr(&with_prefix).unwrap();
2664 assert_eq!(parsed.identifier, "prefixed");
2665 }
2666
2667 #[test]
2668 fn parse_naddr_rejects_garbage_input() {
2669 assert!(parse_naddr("not an naddr").is_err());
2670 assert!(parse_naddr("naddr1invalid").is_err());
2671 assert!(parse_naddr("").is_err());
2672 }
2673
2674 #[test]
2675 fn parse_pack_address_round_trips_valid_input() {
2676 let k = keys();
2677 let hex = k.public_key().to_hex();
2678 let addr = format!("30030:{}:myId", hex);
2679 let parsed = parse_pack_address(&addr).unwrap();
2680 assert_eq!(parsed.kind, 30030);
2681 assert_eq!(parsed.pubkey, k.public_key());
2682 assert_eq!(parsed.identifier, "myId");
2683 }
2684
2685 #[test]
2686 fn parse_pack_address_rejects_wrong_kind() {
2687 let hex = keys().public_key().to_hex();
2688 let addr = format!("10030:{}:x", hex);
2689 assert!(parse_pack_address(&addr).is_err());
2690 }
2691
2692 #[test]
2693 fn parse_pack_address_rejects_malformed_pubkey() {
2694 let addr = "30030:not-hex:x".to_string();
2695 assert!(parse_pack_address(&addr).is_err());
2696 }
2697
2698 #[test]
2699 fn parse_pack_address_preserves_colons_in_identifier() {
2700 let hex = keys().public_key().to_hex();
2702 let addr = format!("30030:{}:id:with:colons", hex);
2703 let parsed = parse_pack_address(&addr).unwrap();
2704 assert_eq!(parsed.identifier, "id:with:colons");
2705 }
2706
2707 #[test]
2708 fn parse_inner_tag_list_extracts_valid_a_tags() {
2709 let hex_a = keys().public_key().to_hex();
2713 let hex_b = keys().public_key().to_hex();
2714 let plaintext = format!(
2715 r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
2716 a = hex_a,
2717 b = hex_b,
2718 );
2719 let addrs = parse_inner_tag_list(&plaintext);
2720 assert_eq!(addrs.len(), 2);
2721 assert_eq!(addrs[0].identifier, "packA");
2722 assert_eq!(addrs[1].identifier, "packB");
2723 }
2724
2725 #[test]
2726 fn parse_inner_tag_list_returns_empty_on_malformed_json() {
2727 assert!(parse_inner_tag_list("not json").is_empty());
2728 assert!(parse_inner_tag_list("").is_empty());
2729 }
2730
2731 #[test]
2732 fn theme_slot_marker_round_trips_through_publish_and_parse() {
2733 let a = keys().public_key().to_hex();
2736 let b = keys().public_key().to_hex();
2737 let c = keys().public_key().to_hex();
2738 let addr_a = format!("30030:{}:packA", a);
2739 let addr_b = format!("30030:{}:packB", b);
2740 let addr_c = format!("30030:{}:packC", c);
2741 let addrs = vec![addr_a.clone(), addr_b.clone(), addr_c.clone()];
2742
2743 let inner = build_inner_tags_with_marker(&addrs, &addr_b);
2744 let marker_count = inner.iter()
2745 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2746 .count();
2747 assert_eq!(marker_count, 1, "exactly one marker is emitted");
2748
2749 let plaintext = serde_json::to_string(&inner).unwrap();
2750 let (parsed, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
2751 assert_eq!(parsed, addrs, "addr order survives the round trip");
2752 assert_eq!(anchor.as_deref(), Some(addr_b.as_str()), "anchor is the 2nd pack");
2753
2754 let old = format!(r#"[["a","{}"],["a","{}"]]"#, addr_a, addr_b);
2756 let (old_addrs, old_anchor) = parse_inner_tag_list_with_anchor(&old);
2757 assert_eq!(old_addrs, vec![addr_a.clone(), addr_b.clone()]);
2758 assert_eq!(old_anchor, None, "a list without the marker has no anchor");
2759
2760 let top = build_inner_tags_with_marker(&addrs, "");
2762 assert_eq!(top[0], vec![THEME_SLOT_TOKEN.to_string()]);
2763 let (_, top_anchor) =
2764 parse_inner_tag_list_with_anchor(&serde_json::to_string(&top).unwrap());
2765 assert_eq!(top_anchor.as_deref(), Some(""), "top slot = empty anchor");
2766
2767 let gone = format!("30030:{}:ghost", keys().public_key().to_hex());
2769 let fallback = build_inner_tags_with_marker(&addrs, &gone);
2770 assert_eq!(fallback[0], vec![THEME_SLOT_TOKEN.to_string()]);
2771 let fb_markers = fallback.iter()
2772 .filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
2773 .count();
2774 assert_eq!(fb_markers, 1, "still exactly one marker on the fallback path");
2775 }
2776
2777 #[test]
2778 fn shortcode_validator_accepts_alphanum_dash_underscore() {
2779 assert!(is_valid_shortcode("smile"));
2780 assert!(is_valid_shortcode("smile_face"));
2781 assert!(is_valid_shortcode("smile-face"));
2782 assert!(is_valid_shortcode("Smile2"));
2783 assert!(!is_valid_shortcode(""));
2784 assert!(!is_valid_shortcode("smile face"));
2785 assert!(!is_valid_shortcode("smile:face"));
2786 assert!(!is_valid_shortcode("😀"));
2787 assert!(!is_valid_shortcode("love~2"));
2790 }
2791
2792 #[test]
2793 fn resolve_token_disambiguates_duplicate_shortcodes() {
2794 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
2796 by_code.insert(
2797 "love".to_string(),
2798 vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
2799 );
2800 by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
2801
2802 assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
2804 assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
2806 assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
2807 assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
2809 assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
2810 assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
2811 assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
2813 assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
2814 }
2815
2816 #[test]
2817 fn message_emoji_tags_accept_disambiguation_separator() {
2818 let tags = vec![
2820 vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
2821 vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
2822 ];
2823 let out = crate::types::EmojiTag::extract_from_stored(&tags);
2824 assert_eq!(out.len(), 1);
2825 assert_eq!(out[0].shortcode, "love~2");
2826 }
2827}