1use std::collections::HashMap;
16
17use nostr_sdk::prelude::*;
18use serde::{Deserialize, Serialize};
19
20use crate::state::nostr_client;
21
22const KIND_EMOJI_LIST: u16 = 10030;
24
25pub const KIND_EMOJI_SET: u16 = 30030;
27
28const KIND_EMOJI_SET_ADDR_PREFIX: &str = "30030:";
34const _: () = assert!(
35 KIND_EMOJI_SET == 30030,
36 "KIND_EMOJI_SET_ADDR_PREFIX literal must match KIND_EMOJI_SET"
37);
38
39fn build_pack_addr(pubkey: &str, identifier: &str) -> String {
44 let mut s = String::with_capacity(
45 KIND_EMOJI_SET_ADDR_PREFIX.len() + pubkey.len() + 1 + identifier.len(),
46 );
47 s.push_str(KIND_EMOJI_SET_ADDR_PREFIX);
48 s.push_str(pubkey);
49 s.push(':');
50 s.push_str(identifier);
51 s
52}
53
54const FETCH_TIMEOUT_SECS: u64 = 20;
60
61pub const MAX_EQUIPPED_PACKS: usize = 3;
66
67pub const MAX_EQUIPPED_PACKS_BADGE: usize = 100;
69
70pub const MAX_EMOJIS_PER_PACK: usize = 30;
75
76pub const MAX_EMOJIS_PER_PACK_BADGE: usize = 100;
78
79pub fn effective_max_equipped_packs() -> usize {
83 if crate::badges::has_vector_badge() {
84 MAX_EQUIPPED_PACKS_BADGE
85 } else {
86 MAX_EQUIPPED_PACKS
87 }
88}
89
90pub fn effective_max_emojis_per_pack() -> usize {
93 if crate::badges::has_vector_badge() {
94 MAX_EMOJIS_PER_PACK_BADGE
95 } else {
96 MAX_EMOJIS_PER_PACK
97 }
98}
99
100#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
105pub struct PackEmoji {
106 pub shortcode: String,
107 pub url: String,
108 pub sha256: Option<String>,
109}
110
111#[derive(Serialize, Deserialize, Clone, Debug)]
112pub struct EmojiPack {
113 pub id: String,
120 pub pubkey: String,
122 pub identifier: String,
124 pub title: String,
126 pub image_url: String,
128 pub description: String,
130 pub emojis: Vec<PackEmoji>,
131 pub is_own: bool,
133 pub updated_at: u64,
136}
137
138impl EmojiPack {
139 pub fn addr(&self) -> String {
143 build_pack_addr(&self.pubkey, &self.identifier)
144 }
145}
146
147fn is_valid_shortcode(s: &str) -> bool {
155 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
156}
157
158static THEME_EMOJI_TAGS: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
163 std::sync::OnceLock::new();
164
165fn theme_emoji_tags() -> &'static std::sync::Mutex<Vec<(String, String)>> {
166 THEME_EMOJI_TAGS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
167}
168
169pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
172 if let Ok(mut g) = theme_emoji_tags().lock() {
173 *g = tags;
174 }
175}
176
177pub fn effective_display_cap() -> usize {
183 if crate::badges::has_vector_badge() { 100 } else { 30 }
184}
185
186fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
191 if let Some((base, suffix)) = token.rsplit_once('~') {
192 let n: usize = suffix.parse().ok()?;
193 if n == 0 { return None; }
194 return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
195 }
196 by_code.get(token).and_then(|urls| urls.first().cloned())
197}
198
199pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
208 if content.is_empty() || !content.contains(':') {
209 return Vec::new();
210 }
211
212 let cap = effective_display_cap();
214 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
215
216 if let Ok(conn) = crate::db::get_db_connection_guard_static() {
220 if let Ok(mut stmt) = conn.prepare(
221 "SELECT p.addr, i.shortcode, i.url
222 FROM emoji_pack_items i
223 INNER JOIN emoji_packs p ON p.addr = i.pack_addr
224 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
225 ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
226 ) {
227 if let Ok(rows) = stmt.query_map([], |row| {
228 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
229 }) {
230 let mut per_pack: HashMap<String, usize> = HashMap::new();
233 for (addr, code, url) in rows.flatten() {
234 let n = per_pack.entry(addr).or_insert(0);
235 if *n >= cap { continue; }
236 *n += 1;
237 by_code.entry(code).or_default().push(url);
238 }
239 }
240 }
241 }
242
243 if let Ok(theme) = theme_emoji_tags().lock() {
247 for (code, url) in theme.iter() {
248 by_code.entry(code.clone()).or_default().push(url.clone());
249 }
250 }
251
252 if by_code.is_empty() {
253 return Vec::new();
254 }
255
256 for urls in by_code.values_mut() {
259 urls.sort();
260 urls.dedup();
261 }
262
263 let mut out: Vec<crate::types::EmojiTag> = Vec::new();
264 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
265 let bytes = content.as_bytes();
266 let mut i = 0;
267 while i < bytes.len() {
268 if bytes[i] == b':' {
269 let start = i + 1;
270 let mut j = start;
271 while j < bytes.len() {
272 let c = bytes[j];
273 let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
275 if !ok { break; }
276 j += 1;
277 }
278 if j > start && j < bytes.len() && bytes[j] == b':' {
279 if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
280 if !seen.contains(token) {
281 if let Some(url) = resolve_emoji_token(&by_code, token) {
282 out.push(crate::types::EmojiTag {
283 shortcode: token.to_string(),
284 url,
285 });
286 seen.insert(token.to_string());
287 }
288 }
289 }
290 i = j + 1;
291 continue;
292 }
293 }
294 i += 1;
295 }
296 out
297}
298
299fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
302 for key in keys {
303 for tag in tags.iter() {
304 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
305 if parts.len() >= 2 && parts[0] == *key {
306 return Some(parts[1].to_string());
307 }
308 }
309 }
310 None
311}
312
313pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
316 if event.kind.as_u16() != KIND_EMOJI_SET {
317 return None;
318 }
319
320 let identifier = first_tag(&event.tags, &["d"])?;
321 let pubkey = event.pubkey.to_hex();
322 let addr = build_pack_addr(&pubkey, &identifier);
323 let id = match naddr_from_addr(&addr) {
324 Ok(s) => s,
325 Err(e) => {
326 crate::log_warn!(
327 "[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
328 addr, e,
329 );
330 return None;
331 }
332 };
333
334 let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
335 let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
336 let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
337
338 let mut emojis = Vec::new();
339 let mut seen = std::collections::HashSet::new();
340 for tag in event.tags.iter() {
341 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
342 if parts.len() >= 3 && parts[0] == "emoji" {
343 let shortcode = parts[1];
344 if !is_valid_shortcode(shortcode) { continue; }
345 let url = parts[2].trim();
349 if url.is_empty() { continue; }
350 if !seen.insert(shortcode.to_string()) { continue; }
351 emojis.push(PackEmoji {
352 shortcode: shortcode.to_string(),
353 url: url.to_string(),
354 sha256: None,
355 });
356 }
357 }
358
359 if emojis.is_empty() {
360 return None;
361 }
362
363 let is_own = my_pubkey_hex.map_or(false, |me| me == pubkey);
364
365 Some(EmojiPack {
366 id,
367 pubkey,
368 identifier,
369 title,
370 image_url,
371 description,
372 emojis,
373 is_own,
374 updated_at: event.created_at.as_secs(),
375 })
376}
377
378#[derive(Debug, Clone, PartialEq)]
380pub struct PackAddress {
381 pub kind: u16,
382 pub pubkey: PublicKey,
383 pub identifier: String,
384}
385
386pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
390 let mut parts = addr.splitn(3, ':');
391 let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
392 let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
393 let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
394
395 let kind: u16 = kind_str.parse()
396 .map_err(|_| format!("invalid kind: {}", kind_str))?;
397 if kind != KIND_EMOJI_SET {
398 return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
399 }
400 let pubkey = PublicKey::from_hex(pubkey_str)
401 .map_err(|e| format!("invalid pubkey: {}", e))?;
402
403 Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
404}
405
406impl PackAddress {
407 pub fn to_addr_string(&self) -> String {
412 debug_assert_eq!(self.kind, KIND_EMOJI_SET,
413 "PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
414 build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
415 }
416}
417
418pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
422 let parsed = parse_pack_address(addr)?;
423 let coord = nostr_sdk::nips::nip01::Coordinate {
424 kind: Kind::Custom(parsed.kind),
425 public_key: parsed.pubkey,
426 identifier: parsed.identifier,
427 };
428 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
429 coordinate: coord,
430 relays: Vec::new(),
431 };
432 nostr_sdk::nips::nip19::Nip19::Coordinate(n19)
433 .to_bech32()
434 .map_err(|e| format!("encode naddr: {}", e))
435}
436
437pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
441 let trimmed = naddr.trim().trim_start_matches("nostr:");
442 let parsed = nostr_sdk::nips::nip19::Nip19::from_bech32(trimmed)
443 .map_err(|e| format!("invalid naddr: {}", e))?;
444 let coord = match parsed {
445 nostr_sdk::nips::nip19::Nip19::Coordinate(c) => c,
446 _ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
447 };
448 let kind = coord.kind.as_u16();
449 if kind != KIND_EMOJI_SET {
450 return Err(format!(
451 "expected kind {} (emoji set), got {}",
452 KIND_EMOJI_SET, kind,
453 ));
454 }
455 Ok(PackAddress {
456 kind,
457 pubkey: coord.public_key,
458 identifier: coord.identifier.clone(),
459 })
460}
461
462fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
467 let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
468 Ok(v) => v,
469 Err(e) => {
470 crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
471 return Vec::new();
472 }
473 };
474 inner.into_iter()
475 .filter_map(|tup| {
476 if tup.len() >= 2 && tup[0] == "a" {
477 parse_pack_address(&tup[1]).ok()
478 } else {
479 None
480 }
481 })
482 .collect()
483}
484
485pub async fn decrypt_subscribed_addresses(
493 client: &Client,
494 my_pk: &PublicKey,
495 event: &Event,
496) -> Vec<PackAddress> {
497 if event.content.is_empty() {
498 return Vec::new();
499 }
500 let signer = match client.signer().await {
501 Ok(s) => s,
502 Err(e) => {
503 crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
504 return Vec::new();
505 }
506 };
507 let plaintext = match signer.nip44_decrypt(my_pk, &event.content).await {
508 Ok(p) => p,
509 Err(e) => {
510 crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
511 return Vec::new();
512 }
513 };
514 parse_inner_tag_list(&plaintext)
515}
516
517pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
522 let mut conn = crate::db::get_write_connection_guard_static()?;
523 let tx = conn.transaction()
524 .map_err(|e| format!("Failed to start tx: {}", e))?;
525
526 let addr = pack.addr();
527 tx.execute(
528 "INSERT OR REPLACE INTO emoji_packs
529 (addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
530 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
531 rusqlite::params![
532 addr, pack.pubkey, pack.identifier,
533 pack.title, pack.image_url, pack.description,
534 pack.is_own as i32, pack.updated_at as i64,
535 ],
536 ).map_err(|e| format!("Failed to upsert pack: {}", e))?;
537
538 tx.execute(
542 "DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
543 rusqlite::params![addr],
544 ).map_err(|e| format!("Failed to clear pack items: {}", e))?;
545
546 for (pos, emoji) in pack.emojis.iter().enumerate() {
547 tx.execute(
548 "INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
549 VALUES (?1, ?2, ?3, ?4, ?5)",
550 rusqlite::params![
551 addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
552 ],
553 ).map_err(|e| format!("Failed to insert pack item: {}", e))?;
554 }
555
556 tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
557 Ok(())
558}
559
560pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
561 let mut conn = crate::db::get_write_connection_guard_static()?;
562 let tx = conn.transaction()
563 .map_err(|e| format!("Failed to start tx: {}", e))?;
564
565 tx.execute("DELETE FROM emoji_pack_subscriptions", [])
566 .map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
567
568 let now = std::time::SystemTime::now()
569 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
570 for addr in addrs {
571 tx.execute(
572 "INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at)
573 VALUES (?1, ?2)",
574 rusqlite::params![addr, now],
575 ).map_err(|e| format!("Failed to insert subscription: {}", e))?;
576 }
577
578 tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
579 Ok(())
580}
581
582pub fn load_subscriptions() -> Result<Vec<String>, String> {
583 let conn = crate::db::get_db_connection_guard_static()?;
584 let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY subscribed_at ASC")
585 .map_err(|e| format!("prepare: {}", e))?;
586 let rows = stmt.query_map([], |row| row.get::<_, String>(0))
587 .map_err(|e| format!("query: {}", e))?;
588 let mut out = Vec::new();
589 for r in rows {
590 out.push(r.map_err(|e| format!("row: {}", e))?);
591 }
592 Ok(out)
593}
594
595pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
600 let conn = crate::db::get_db_connection_guard_static()?;
601
602 let mut pack = match conn.query_row(
603 "SELECT pubkey, identifier, title, image_url, description, is_own, updated_at
604 FROM emoji_packs WHERE addr = ?1",
605 rusqlite::params![addr],
606 |row| {
607 Ok(EmojiPack {
608 id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
609 pubkey: row.get(0)?,
610 identifier: row.get(1)?,
611 title: row.get(2)?,
612 image_url: row.get(3)?,
613 description: row.get(4)?,
614 is_own: row.get::<_, i32>(5)? != 0,
615 updated_at: row.get::<_, i64>(6)? as u64,
616 emojis: Vec::new(),
617 })
618 },
619 ) {
620 Ok(p) => p,
621 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
622 Err(e) => return Err(format!("query cached pack: {}", e)),
623 };
624
625 let mut stmt = conn.prepare(
626 "SELECT shortcode, url, sha256 FROM emoji_pack_items
627 WHERE pack_addr = ?1 ORDER BY position ASC"
628 ).map_err(|e| format!("prepare items: {}", e))?;
629 let rows = stmt.query_map(rusqlite::params![addr], |row| {
630 Ok(PackEmoji {
631 shortcode: row.get(0)?,
632 url: row.get(1)?,
633 sha256: row.get(2)?,
634 })
635 }).map_err(|e| format!("query items: {}", e))?;
636 for r in rows {
637 pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
638 }
639
640 Ok(Some(pack))
641}
642
643pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
648 let conn = crate::db::get_db_connection_guard_static()?;
649
650 let mut packs: HashMap<String, EmojiPack> = HashMap::new();
651 let mut order: Vec<String> = Vec::new();
652
653 {
654 let mut stmt = conn.prepare(
661 "SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at
662 FROM emoji_packs p
663 INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
664 ORDER BY p.is_own DESC, p.updated_at DESC"
665 ).map_err(|e| format!("prepare packs: {}", e))?;
666
667 let rows = stmt.query_map([], |row| {
668 let raw_addr: String = row.get(0)?;
672 let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
673 Ok((raw_addr, EmojiPack {
674 id,
675 pubkey: row.get(1)?,
676 identifier: row.get(2)?,
677 title: row.get(3)?,
678 image_url: row.get(4)?,
679 description: row.get(5)?,
680 is_own: row.get::<_, i32>(6)? != 0,
681 updated_at: row.get::<_, i64>(7)? as u64,
682 emojis: Vec::new(),
683 }))
684 }).map_err(|e| format!("query packs: {}", e))?;
685
686 for r in rows {
687 let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
688 order.push(raw_addr.clone());
689 packs.insert(raw_addr, pack);
690 }
691 }
692
693 {
694 let mut stmt = conn.prepare(
695 "SELECT pack_addr, shortcode, url, sha256
696 FROM emoji_pack_items
697 ORDER BY pack_addr, position ASC"
698 ).map_err(|e| format!("prepare items: {}", e))?;
699
700 let rows = stmt.query_map([], |row| {
701 Ok((
702 row.get::<_, String>(0)?,
703 PackEmoji {
704 shortcode: row.get(1)?,
705 url: row.get(2)?,
706 sha256: row.get(3)?,
707 },
708 ))
709 }).map_err(|e| format!("query items: {}", e))?;
710
711 for r in rows {
712 let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
713 if let Some(p) = packs.get_mut(&addr) {
714 p.emojis.push(emoji);
715 }
716 }
717 }
718
719 Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
720}
721
722const NIP65_CACHE_TTL_SECS: u64 = 3600;
731const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
734const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
735
736#[derive(Clone)]
737struct CachedRelayList {
738 relays: Vec<RelayUrl>,
739 fetched_at: std::time::Instant,
740 empty: bool,
742}
743
744static NIP65_CACHE: std::sync::OnceLock<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> =
745 std::sync::OnceLock::new();
746
747fn nip65_cache() -> &'static std::sync::RwLock<HashMap<PublicKey, CachedRelayList>> {
748 NIP65_CACHE.get_or_init(|| std::sync::RwLock::new(HashMap::new()))
749}
750
751fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
754 let cache = nip65_cache().read().ok()?;
755 let entry = cache.get(pubkey)?;
756 let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
757 if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
758 Some(entry.relays.clone())
759 } else {
760 None
761 }
762}
763
764pub fn clear_nip65_cache() {
767 if let Ok(mut cache) = nip65_cache().write() {
768 cache.clear();
769 }
770}
771
772fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>) {
774 if let Ok(mut cache) = nip65_cache().write() {
775 let empty = relays.is_empty();
776 cache.insert(pubkey, CachedRelayList {
777 relays,
778 fetched_at: std::time::Instant::now(),
779 empty,
780 });
781 }
782}
783
784fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
788 let mut relays: Vec<RelayUrl> = Vec::new();
789 for (url, marker) in nostr_sdk::nips::nip65::extract_relay_list(ev) {
790 match marker {
791 None | Some(nostr_sdk::nips::nip65::RelayMetadata::Write) => {
792 if !relays.contains(url) {
793 relays.push(url.clone());
794 }
795 }
796 Some(nostr_sdk::nips::nip65::RelayMetadata::Read) => {}
797 }
798 }
799 relays
800}
801
802async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
807 if let Some(relays) = cached_write_relays(&pubkey) {
808 return relays;
809 }
810
811 let filter = Filter::new()
812 .author(pubkey)
813 .kind(Kind::RelayList)
814 .limit(1);
815 let events = match client
816 .fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
817 .await
818 {
819 Ok(evs) => evs,
820 Err(_) => {
821 cache_write_relays(pubkey, Vec::new());
822 return Vec::new();
823 }
824 };
825
826 let relays = events.into_iter()
827 .max_by_key(|e| e.created_at)
828 .map(|ev| extract_write_relays(&ev))
829 .unwrap_or_default();
830 cache_write_relays(pubkey, relays.clone());
831 relays
832}
833
834async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
840 let uncached: Vec<PublicKey> = authors.iter()
841 .filter(|pk| cached_write_relays(pk).is_none())
842 .copied()
843 .collect();
844 if uncached.is_empty() {
845 return;
846 }
847
848 let filter = Filter::new()
849 .authors(uncached.iter().copied())
850 .kind(Kind::RelayList);
851 let events = match client
852 .fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
853 .await
854 {
855 Ok(evs) => evs,
856 Err(_) => return,
859 };
860
861 let mut newest: HashMap<PublicKey, Event> = HashMap::new();
863 for ev in events {
864 match newest.get(&ev.pubkey) {
865 Some(existing) if existing.created_at >= ev.created_at => {}
866 _ => { newest.insert(ev.pubkey, ev); }
867 }
868 }
869 for pk in uncached {
870 let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
871 cache_write_relays(pk, relays);
872 }
873}
874
875async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
884 let filter = Filter::new()
885 .author(addr.pubkey)
886 .kind(Kind::Custom(KIND_EMOJI_SET))
887 .identifier(&addr.identifier)
888 .limit(1);
889 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
890 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
891
892 match client.fetch_events(filter.clone(), timeout).await {
895 Ok(events) => {
896 if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
897 if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
898 return Some(pack);
899 }
900 }
901 }
902 Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
903 }
904
905 let outbox = fetch_author_write_relays(client, addr.pubkey).await;
911 if outbox.is_empty() {
912 return None;
913 }
914 fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
915}
916
917async fn fetch_pack_via_isolated_client(
921 relays: &[RelayUrl],
922 filter: Filter,
923 timeout: std::time::Duration,
924 my_pubkey_hex: Option<&str>,
925) -> Option<EmojiPack> {
926 let scratch = ClientBuilder::new()
927 .opts(crate::nostr_client_options())
928 .build();
929 for r in relays {
930 let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
931 let _ = scratch.pool().add_relay(r.as_str(), opts).await;
932 }
933 scratch.connect().await;
934
935 let result = scratch.fetch_events(filter, timeout).await;
936 scratch.shutdown().await;
938
939 let events = match result {
940 Ok(events) => events,
941 Err(e) => {
942 crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
943 return None;
944 }
945 };
946 let event = events.into_iter().max_by_key(|e| e.created_at)?;
947 parse_pack_from_event(&event, my_pubkey_hex)
948}
949
950fn event_coord(ev: &Event) -> Option<String> {
969 let d = first_tag(&ev.tags, &["d"])?;
970 Some(format!("{}:{}", ev.pubkey.to_hex(), d))
971}
972fn addr_coord(addr: &PackAddress) -> String {
973 format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
974}
975
976fn parse_packs_by_coord(
981 events: impl IntoIterator<Item = Event>,
982 wanted: &std::collections::HashSet<String>,
983 me: Option<&str>,
984) -> HashMap<String, EmojiPack> {
985 let mut newest: HashMap<String, Event> = HashMap::new();
986 for ev in events {
987 if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
988 let Some(coord) = event_coord(&ev) else { continue; };
989 if !wanted.contains(&coord) { continue; }
990 match newest.get(&coord) {
991 Some(existing) if existing.created_at >= ev.created_at => {}
992 _ => { newest.insert(coord, ev); }
993 }
994 }
995 newest.into_iter()
996 .filter_map(|(coord, ev)| parse_pack_from_event(&ev, me).map(|p| (coord, p)))
997 .collect()
998}
999
1000async fn fetch_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> Vec<EmojiPack> {
1005 if addrs.is_empty() {
1006 return Vec::new();
1007 }
1008 let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
1009 let me = crate::state::my_public_key().map(|pk| pk.to_hex());
1010 let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
1011
1012 let home_filter = Filter::new()
1014 .authors(addrs.iter().map(|a| a.pubkey))
1015 .kind(Kind::Custom(KIND_EMOJI_SET))
1016 .identifiers(addrs.iter().map(|a| a.identifier.clone()));
1017 let mut resolved: HashMap<String, EmojiPack> = match client.fetch_events(home_filter, timeout).await {
1018 Ok(events) => parse_packs_by_coord(events, &wanted, me.as_deref()),
1019 Err(e) => {
1020 crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
1021 HashMap::new()
1022 }
1023 };
1024
1025 let misses: Vec<&PackAddress> = addrs.iter()
1027 .filter(|a| !resolved.contains_key(&addr_coord(a)))
1028 .collect();
1029 if !misses.is_empty() {
1030 let miss_authors: Vec<PublicKey> = {
1031 let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
1032 v.sort(); v.dedup();
1033 v
1034 };
1035 prefetch_author_write_relays(client, &miss_authors).await;
1038 let mut outbox: Vec<RelayUrl> = Vec::new();
1039 for pk in &miss_authors {
1040 for r in cached_write_relays(pk).unwrap_or_default() {
1041 if !outbox.contains(&r) { outbox.push(r); }
1042 }
1043 }
1044 if !outbox.is_empty() {
1045 let miss_filter = Filter::new()
1046 .authors(misses.iter().map(|a| a.pubkey))
1047 .kind(Kind::Custom(KIND_EMOJI_SET))
1048 .identifiers(misses.iter().map(|a| a.identifier.clone()));
1049 let wanted_misses: std::collections::HashSet<String> =
1050 misses.iter().map(|a| addr_coord(a)).collect();
1051 if let Some(packs) = fetch_packs_via_isolated_client(
1052 &outbox, miss_filter, timeout, &wanted_misses, me.as_deref(),
1053 ).await {
1054 resolved.extend(packs);
1055 }
1056 }
1057 }
1058
1059 addrs.iter()
1061 .filter_map(|a| resolved.remove(&addr_coord(a)))
1062 .collect()
1063}
1064
1065async fn fetch_packs_via_isolated_client(
1068 relays: &[RelayUrl],
1069 filter: Filter,
1070 timeout: std::time::Duration,
1071 wanted: &std::collections::HashSet<String>,
1072 my_pubkey_hex: Option<&str>,
1073) -> Option<HashMap<String, EmojiPack>> {
1074 let scratch = ClientBuilder::new()
1075 .opts(crate::nostr_client_options())
1076 .build();
1077 for r in relays {
1078 let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
1079 let _ = scratch.pool().add_relay(r.as_str(), opts).await;
1080 }
1081 scratch.connect().await;
1082 let result = scratch.fetch_events(filter, timeout).await;
1083 scratch.shutdown().await;
1084
1085 match result {
1086 Ok(events) => Some(parse_packs_by_coord(events, wanted, my_pubkey_hex)),
1087 Err(e) => {
1088 crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
1089 None
1090 }
1091 }
1092}
1093
1094pub async fn fetch_subscribed_packs(
1103 client: &Client,
1104 my_pubkey: PublicKey,
1105 session: crate::state::SessionGuard,
1106) -> Result<Vec<EmojiPack>, String> {
1107 let list_filter = Filter::new()
1108 .author(my_pubkey)
1109 .kind(Kind::Custom(KIND_EMOJI_LIST))
1110 .limit(1);
1111
1112 let list_events = client
1113 .fetch_events(list_filter, std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
1114 .await
1115 .map_err(|e| format!("fetch kind 10030: {}", e))?;
1116
1117 if !session.is_valid() {
1118 return Ok(Vec::new());
1119 }
1120
1121 let local_addrs = || -> Vec<PackAddress> {
1129 load_subscriptions()
1130 .unwrap_or_default()
1131 .into_iter()
1132 .filter_map(|s| parse_pack_address(&s).ok())
1133 .collect()
1134 };
1135 let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
1136 .ok()
1137 .flatten()
1138 .and_then(|s| s.parse().ok())
1139 .unwrap_or(0);
1140
1141 let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
1142 let addrs: Vec<PackAddress> = match list_event {
1143 Some(ev) if ev.created_at.as_secs() < our_last_publish => {
1144 crate::log_debug!(
1145 "[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
1146 ev.created_at.as_secs(), our_last_publish,
1147 );
1148 local_addrs()
1149 }
1150 Some(ev) => decrypt_subscribed_addresses(client, &my_pubkey, &ev).await,
1151 None => {
1152 crate::log_debug!(
1153 "[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
1154 );
1155 local_addrs()
1156 }
1157 };
1158
1159 let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
1160
1161 let fresh: Vec<EmojiPack> = fetch_packs_from_relays(client, &addrs).await;
1167 let fetch_failures = addrs.len().saturating_sub(fresh.len());
1168 if fetch_failures > 0 {
1169 crate::log_warn!(
1170 "[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
1171 fetch_failures,
1172 );
1173 }
1174
1175 if !session.is_valid() {
1176 return Ok(fresh);
1177 }
1178
1179 for pack in &fresh {
1180 if let Err(e) = save_pack(pack) {
1181 crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
1182 }
1183 }
1184 if let Err(e) = save_subscriptions(&addr_strings) {
1188 crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
1189 }
1190
1191 crate::log_info!(
1192 "[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
1193 fresh.len(),
1194 addrs.len(),
1195 if fetch_failures > 0 {
1196 format!(" ({} via cache)", fetch_failures)
1197 } else {
1198 String::new()
1199 },
1200 );
1201
1202 load_all_packs()
1205}
1206
1207pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
1212 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1213 let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1214 let session = crate::state::SessionGuard::capture();
1215 fetch_subscribed_packs(&client, me, session).await
1216}
1217
1218pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
1222 let addr = parse_naddr(naddr)?;
1223 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1224 fetch_pack_from_relays(&client, &addr).await
1225 .ok_or_else(|| format!("Pack not found on any relay: {}:{}", addr.pubkey.to_hex(), addr.identifier))
1226}
1227
1228pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
1236 let addr = parse_naddr(naddr)?;
1237 let coord = addr.to_addr_string();
1238
1239 if let Some(cached) = load_cached_pack(&coord)? {
1242 let naddr_owned = naddr.to_string();
1243 let session = crate::state::SessionGuard::capture();
1244 tokio::spawn(async move {
1245 if !session.is_valid() { return; }
1246 let Some(client) = nostr_client() else { return };
1247 if let Ok(parsed) = parse_naddr(&naddr_owned) {
1248 if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
1249 if session.is_valid() && fresh.updated_at > cached.updated_at {
1250 if let Err(e) = save_pack(&fresh) {
1251 crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
1252 } else {
1253 crate::traits::emit_event("emoji_packs_updated", &());
1254 }
1255 }
1256 }
1257 }
1258 });
1259 return Ok(Some(cached));
1260 }
1261
1262 let session = crate::state::SessionGuard::capture();
1264 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1265 match fetch_pack_from_relays(&client, &addr).await {
1266 Some(pack) => {
1267 if session.is_valid() {
1271 if let Err(e) = save_pack(&pack) {
1272 crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
1273 }
1274 }
1275 Ok(Some(pack))
1276 }
1277 None => Ok(None),
1278 }
1279}
1280
1281pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
1291 let addrs = load_subscriptions()?;
1292 let my_pk = crate::state::my_public_key()
1293 .ok_or_else(|| "Not logged in".to_string())?;
1294
1295 let inner_tags: Vec<Vec<String>> = addrs.iter()
1296 .map(|addr| vec!["a".to_string(), addr.clone()])
1297 .collect();
1298 let plaintext = serde_json::to_string(&inner_tags)
1299 .map_err(|e| format!("Serialise emoji list: {}", e))?;
1300
1301 let signer = client.signer().await
1302 .map_err(|e| format!("Signer unavailable: {}", e))?;
1303 let content = signer.nip44_encrypt(&my_pk, &plaintext).await
1304 .map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
1305
1306 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
1307 client.send_event_builder(builder).await
1308 .map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
1309
1310 crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
1311 Ok(())
1312}
1313
1314const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
1318
1319static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1320
1321pub fn republish_emoji_list_debounced() {
1325 use std::sync::atomic::Ordering;
1326 let _ = crate::db::settings::set_sql_setting(
1332 EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
1333 Timestamp::now().as_secs().to_string(),
1334 );
1335 let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
1336 let session = crate::state::SessionGuard::capture();
1337 tokio::spawn(async move {
1338 tokio::time::sleep(std::time::Duration::from_millis(800)).await;
1339 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1340 if !session.is_valid() { return; }
1341 let client = match nostr_client() {
1342 Some(c) => c,
1343 None => return,
1344 };
1345 if let Err(e) = publish_emoji_list(&client).await {
1346 crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
1347 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1348 if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
1349 if !session.is_valid() { return; }
1350 if let Err(e2) = publish_emoji_list(&client).await {
1351 crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
1352 }
1353 }
1354 });
1355}
1356
1357pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
1361 let session = crate::state::SessionGuard::capture();
1362 let pack = fetch_pack_by_naddr(naddr).await?;
1363 if !session.is_valid() {
1364 return Err("Account swapped during fetch — aborted".to_string());
1365 }
1366
1367 let pack_addr = pack.addr();
1370 {
1371 let existing_subs = load_subscriptions()?;
1372 let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
1373 let cap = effective_max_equipped_packs();
1374 if is_new && existing_subs.len() >= cap {
1375 return Err(format!(
1376 "You can equip at most {} packs. Remove one to add another.",
1377 cap,
1378 ));
1379 }
1380 }
1381
1382 save_pack(&pack)?;
1383
1384 let mut subs = load_subscriptions()?;
1385 if !subs.iter().any(|a| a == &pack_addr) {
1386 subs.push(pack_addr.clone());
1387 }
1388 if !session.is_valid() {
1389 return Err("Account swapped before subscription save — aborted".to_string());
1390 }
1391 save_subscriptions(&subs)?;
1392
1393 republish_emoji_list_debounced();
1394 crate::traits::emit_event("emoji_packs_updated", &());
1395
1396 Ok(pack)
1397}
1398
1399fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
1408 if pack.identifier.is_empty() {
1409 return Err("pack identifier required".to_string());
1410 }
1411 let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1412 .tag(Tag::custom(TagKind::custom("d"), [pack.identifier.clone()]));
1413
1414 if !pack.title.is_empty() {
1416 builder = builder
1417 .tag(Tag::custom(TagKind::custom("title"), [pack.title.clone()]))
1418 .tag(Tag::custom(TagKind::custom("name"), [pack.title.clone()]));
1419 }
1420 if !pack.image_url.is_empty() {
1421 builder = builder
1422 .tag(Tag::custom(TagKind::custom("image"), [pack.image_url.clone()]))
1423 .tag(Tag::custom(TagKind::custom("picture"), [pack.image_url.clone()]));
1424 }
1425 if !pack.description.is_empty() {
1426 builder = builder
1427 .tag(Tag::custom(TagKind::custom("description"), [pack.description.clone()]))
1428 .tag(Tag::custom(TagKind::custom("about"), [pack.description.clone()]));
1429 }
1430
1431 for e in &pack.emojis {
1432 if e.shortcode.is_empty() || e.url.is_empty() { continue; }
1433 builder = builder.tag(Tag::custom(
1434 TagKind::custom("emoji"),
1435 [e.shortcode.clone(), e.url.clone()],
1436 ));
1437 }
1438 Ok(builder)
1439}
1440
1441pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
1446 let session = crate::state::SessionGuard::capture();
1447 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1448 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1449
1450 let emoji_cap = effective_max_emojis_per_pack();
1453 if pack.emojis.len() > emoji_cap {
1454 return Err(format!(
1455 "A pack can hold at most {} emojis.",
1456 emoji_cap,
1457 ));
1458 }
1459
1460 let mut to_save = pack.clone();
1463 to_save.pubkey = my_pk.to_hex();
1464 to_save.is_own = true;
1465 let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
1466 to_save.id = naddr_from_addr(&raw_addr)?;
1467 to_save.updated_at = std::time::SystemTime::now()
1468 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1469
1470 {
1473 let existing_subs = load_subscriptions()?;
1474 let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
1475 let cap = effective_max_equipped_packs();
1476 if is_new && existing_subs.len() >= cap {
1477 return Err(format!(
1478 "You can equip at most {} packs. Remove one to add another.",
1479 cap,
1480 ));
1481 }
1482 }
1483
1484 let builder = build_pack_event(&to_save)?;
1485 client.send_event_builder(builder).await
1486 .map_err(|e| format!("publish kind 30030: {}", e))?;
1487
1488 if !session.is_valid() {
1489 return Err("Account swapped during publish — local state untouched".to_string());
1490 }
1491
1492 save_pack(&to_save)?;
1493
1494 let mut subs = load_subscriptions()?;
1497 if !subs.iter().any(|a| a == &raw_addr) {
1498 subs.push(raw_addr.clone());
1499 if !session.is_valid() {
1500 return Err("Account swapped before subscription save — aborted".to_string());
1501 }
1502 save_subscriptions(&subs)?;
1503 republish_emoji_list_debounced();
1504 }
1505
1506 crate::traits::emit_event("emoji_packs_updated", &());
1507 crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
1508 to_save.identifier, to_save.emojis.len());
1509
1510 Ok(to_save)
1511}
1512
1513pub async fn delete_own_pack(id: &str) -> Result<(), String> {
1517 let session = crate::state::SessionGuard::capture();
1518 let parsed = parse_naddr(id)?;
1519 let raw_addr = parsed.to_addr_string();
1520 let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
1521 if parsed.pubkey != my_pk {
1522 return Err("Cannot delete a pack you don't own".to_string());
1523 }
1524 let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
1525
1526 let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1527 .tag(Tag::custom(TagKind::custom("d"), [parsed.identifier.clone()]));
1528 client.send_event_builder(builder).await
1529 .map_err(|e| format!("publish empty kind 30030: {}", e))?;
1530
1531 if !session.is_valid() {
1532 return Err("Account swapped during delete — local state untouched".to_string());
1533 }
1534
1535 {
1539 let mut conn = crate::db::get_write_connection_guard_static()?;
1540 let tx = conn.transaction()
1541 .map_err(|e| format!("begin delete tx: {}", e))?;
1542 tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
1543 rusqlite::params![raw_addr])
1544 .map_err(|e| format!("drop subscription: {}", e))?;
1545 tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
1546 rusqlite::params![raw_addr])
1547 .map_err(|e| format!("drop pack row: {}", e))?;
1548 tx.commit()
1549 .map_err(|e| format!("commit delete tx: {}", e))?;
1550 }
1551
1552 republish_emoji_list_debounced();
1553 crate::traits::emit_event("emoji_packs_updated", &());
1554 crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
1555 Ok(())
1556}
1557
1558pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
1562 let session = crate::state::SessionGuard::capture();
1563 let raw_addr = parse_naddr(id)?.to_addr_string();
1564 let mut subs = load_subscriptions()?;
1565 let before = subs.len();
1566 subs.retain(|a| a != &raw_addr);
1567 if subs.len() == before {
1568 return Ok(()); }
1570 if !session.is_valid() {
1571 return Err("Account swapped before unsubscribe save — aborted".to_string());
1572 }
1573 save_subscriptions(&subs)?;
1574 republish_emoji_list_debounced();
1575 crate::traits::emit_event("emoji_packs_updated", &());
1576 Ok(())
1577}
1578
1579#[cfg(test)]
1584mod tests {
1585 use super::*;
1586
1587 fn keys() -> Keys {
1588 Keys::generate()
1589 }
1590
1591 fn build_pack_event(
1592 k: &Keys,
1593 d: &str,
1594 title_tag: Option<(&str, &str)>,
1595 image_tag: Option<(&str, &str)>,
1596 desc_tag: Option<(&str, &str)>,
1597 emojis: &[(&str, &str)],
1598 ) -> Event {
1599 let mut tags: Vec<Tag> = Vec::new();
1600 tags.push(Tag::custom(TagKind::custom("d"), [d]));
1601 if let Some((key, val)) = title_tag {
1602 tags.push(Tag::custom(TagKind::custom(key), [val]));
1603 }
1604 if let Some((key, val)) = image_tag {
1605 tags.push(Tag::custom(TagKind::custom(key), [val]));
1606 }
1607 if let Some((key, val)) = desc_tag {
1608 tags.push(Tag::custom(TagKind::custom(key), [val]));
1609 }
1610 for (code, url) in emojis {
1611 tags.push(Tag::custom(TagKind::custom("emoji"), [*code, *url]));
1612 }
1613 EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1614 .tags(tags)
1615 .sign_with_keys(k)
1616 .unwrap()
1617 }
1618
1619 #[test]
1620 fn parse_pack_reads_nip51_spec_tags() {
1621 let k = keys();
1622 let ev = build_pack_event(
1623 &k, "myPack",
1624 Some(("title", "Spec Pack")),
1625 Some(("image", "https://example.com/p.png")),
1626 Some(("description", "specd")),
1627 &[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
1628 );
1629 let pack = parse_pack_from_event(&ev, None).unwrap();
1630 assert_eq!(pack.identifier, "myPack");
1631 assert_eq!(pack.title, "Spec Pack");
1632 assert_eq!(pack.image_url, "https://example.com/p.png");
1633 assert_eq!(pack.description, "specd");
1634 assert_eq!(pack.emojis.len(), 2);
1635 assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
1636 }
1637
1638 #[test]
1639 fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
1640 let k = keys();
1641 let ev = build_pack_event(
1642 &k, "ditto",
1643 Some(("name", "Ditto Pack")),
1644 Some(("picture", "https://example.com/d.png")),
1645 Some(("about", "ditto-style")),
1646 &[("yes", "https://e.x/y.png")],
1647 );
1648 let pack = parse_pack_from_event(&ev, None).unwrap();
1649 assert_eq!(pack.title, "Ditto Pack");
1650 assert_eq!(pack.image_url, "https://example.com/d.png");
1651 assert_eq!(pack.description, "ditto-style");
1652 }
1653
1654 #[test]
1655 fn parse_pack_prefers_spec_tags_over_ditto() {
1656 let k = keys();
1657 let mut tags: Vec<Tag> = vec![
1658 Tag::custom(TagKind::custom("d"), ["both"]),
1659 Tag::custom(TagKind::custom("title"), ["SpecTitle"]),
1660 Tag::custom(TagKind::custom("name"), ["DittoName"]),
1661 Tag::custom(TagKind::custom("image"), ["spec.png"]),
1662 Tag::custom(TagKind::custom("picture"), ["ditto.png"]),
1663 Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
1664 ];
1665 tags.extend(std::iter::empty());
1666 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1667 .tags(tags).sign_with_keys(&k).unwrap();
1668 let pack = parse_pack_from_event(&ev, None).unwrap();
1669 assert_eq!(pack.title, "SpecTitle");
1670 assert_eq!(pack.image_url, "spec.png");
1671 }
1672
1673 #[test]
1674 fn parse_pack_returns_none_without_d_tag() {
1675 let k = keys();
1676 let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
1677 .tags(vec![
1678 Tag::custom(TagKind::custom("title"), ["No D"]),
1679 Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
1680 ])
1681 .sign_with_keys(&k).unwrap();
1682 assert!(parse_pack_from_event(&ev, None).is_none());
1683 }
1684
1685 #[test]
1686 fn parse_pack_returns_none_when_no_valid_emojis() {
1687 let k = keys();
1688 let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
1689 assert!(parse_pack_from_event(&ev, None).is_none());
1690 }
1691
1692 #[test]
1693 fn parse_pack_rejects_invalid_shortcodes() {
1694 let k = keys();
1695 let ev = build_pack_event(
1696 &k, "mix", Some(("title", "Mix")), None, None,
1697 &[("ok_name", "https://e.x/a.png"),
1698 ("bad name", "https://e.x/b.png"),
1699 ("colons:no", "https://e.x/c.png"),
1700 ("", "https://e.x/d.png"),
1701 ("dash-ok", "https://e.x/e.png")],
1702 );
1703 let pack = parse_pack_from_event(&ev, None).unwrap();
1704 let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
1705 assert_eq!(codes, vec!["ok_name", "dash-ok"]);
1706 }
1707
1708 #[test]
1709 fn parse_pack_dedupes_shortcodes_first_wins() {
1710 let k = keys();
1711 let ev = build_pack_event(
1712 &k, "dup", Some(("title", "Dup")), None, None,
1713 &[("smile", "https://e.x/first.png"),
1714 ("smile", "https://e.x/second.png")],
1715 );
1716 let pack = parse_pack_from_event(&ev, None).unwrap();
1717 assert_eq!(pack.emojis.len(), 1);
1718 assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
1719 }
1720
1721 #[test]
1722 fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
1723 let k = keys();
1724 let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
1725 &[("a", "https://e.x/a.png")]);
1726 let my_hex = k.public_key().to_hex();
1727 let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
1728 assert!(pack_mine.is_own);
1729
1730 let stranger = keys().public_key().to_hex();
1731 let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
1732 assert!(!pack_other.is_own);
1733 }
1734
1735 #[test]
1736 fn pack_address_to_string_round_trips() {
1737 let k = keys();
1738 let hex = k.public_key().to_hex();
1739 let addr = PackAddress {
1740 kind: 30030,
1741 pubkey: k.public_key(),
1742 identifier: "myPack".to_string(),
1743 };
1744 assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
1745 let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
1746 assert_eq!(parsed, addr);
1747 }
1748
1749 #[test]
1750 fn parse_naddr_round_trips_kind_30030_coordinate() {
1751 let k = keys();
1754 let coord = nostr_sdk::nips::nip01::Coordinate {
1755 kind: Kind::Custom(30030),
1756 public_key: k.public_key(),
1757 identifier: "trip".to_string(),
1758 };
1759 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
1760 coordinate: coord,
1761 relays: Vec::new(),
1762 };
1763 let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
1764 let parsed = parse_naddr(&naddr).unwrap();
1765 assert_eq!(parsed.kind, 30030);
1766 assert_eq!(parsed.pubkey, k.public_key());
1767 assert_eq!(parsed.identifier, "trip");
1768 }
1769
1770 #[test]
1771 fn parse_naddr_rejects_non_30030_kinds() {
1772 let k = keys();
1773 let coord = nostr_sdk::nips::nip01::Coordinate {
1774 kind: Kind::Custom(30023), public_key: k.public_key(),
1776 identifier: "essay".to_string(),
1777 };
1778 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
1779 coordinate: coord,
1780 relays: Vec::new(),
1781 };
1782 let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
1783 let err = parse_naddr(&naddr).unwrap_err();
1784 assert!(err.contains("expected kind 30030"),
1785 "expected kind-rejection error, got: {}", err);
1786 }
1787
1788 #[test]
1789 fn parse_naddr_strips_nostr_uri_prefix() {
1790 let k = keys();
1791 let coord = nostr_sdk::nips::nip01::Coordinate {
1792 kind: Kind::Custom(30030),
1793 public_key: k.public_key(),
1794 identifier: "prefixed".to_string(),
1795 };
1796 let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
1797 coordinate: coord,
1798 relays: Vec::new(),
1799 };
1800 let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
1801 let with_prefix = format!("nostr:{}", naddr);
1802 let parsed = parse_naddr(&with_prefix).unwrap();
1803 assert_eq!(parsed.identifier, "prefixed");
1804 }
1805
1806 #[test]
1807 fn parse_naddr_rejects_garbage_input() {
1808 assert!(parse_naddr("not an naddr").is_err());
1809 assert!(parse_naddr("naddr1invalid").is_err());
1810 assert!(parse_naddr("").is_err());
1811 }
1812
1813 #[test]
1814 fn parse_pack_address_round_trips_valid_input() {
1815 let k = keys();
1816 let hex = k.public_key().to_hex();
1817 let addr = format!("30030:{}:myId", hex);
1818 let parsed = parse_pack_address(&addr).unwrap();
1819 assert_eq!(parsed.kind, 30030);
1820 assert_eq!(parsed.pubkey, k.public_key());
1821 assert_eq!(parsed.identifier, "myId");
1822 }
1823
1824 #[test]
1825 fn parse_pack_address_rejects_wrong_kind() {
1826 let hex = keys().public_key().to_hex();
1827 let addr = format!("10030:{}:x", hex);
1828 assert!(parse_pack_address(&addr).is_err());
1829 }
1830
1831 #[test]
1832 fn parse_pack_address_rejects_malformed_pubkey() {
1833 let addr = "30030:not-hex:x".to_string();
1834 assert!(parse_pack_address(&addr).is_err());
1835 }
1836
1837 #[test]
1838 fn parse_pack_address_preserves_colons_in_identifier() {
1839 let hex = keys().public_key().to_hex();
1841 let addr = format!("30030:{}:id:with:colons", hex);
1842 let parsed = parse_pack_address(&addr).unwrap();
1843 assert_eq!(parsed.identifier, "id:with:colons");
1844 }
1845
1846 #[test]
1847 fn parse_inner_tag_list_extracts_valid_a_tags() {
1848 let hex_a = keys().public_key().to_hex();
1852 let hex_b = keys().public_key().to_hex();
1853 let plaintext = format!(
1854 r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
1855 a = hex_a,
1856 b = hex_b,
1857 );
1858 let addrs = parse_inner_tag_list(&plaintext);
1859 assert_eq!(addrs.len(), 2);
1860 assert_eq!(addrs[0].identifier, "packA");
1861 assert_eq!(addrs[1].identifier, "packB");
1862 }
1863
1864 #[test]
1865 fn parse_inner_tag_list_returns_empty_on_malformed_json() {
1866 assert!(parse_inner_tag_list("not json").is_empty());
1867 assert!(parse_inner_tag_list("").is_empty());
1868 }
1869
1870 #[test]
1871 fn shortcode_validator_accepts_alphanum_dash_underscore() {
1872 assert!(is_valid_shortcode("smile"));
1873 assert!(is_valid_shortcode("smile_face"));
1874 assert!(is_valid_shortcode("smile-face"));
1875 assert!(is_valid_shortcode("Smile2"));
1876 assert!(!is_valid_shortcode(""));
1877 assert!(!is_valid_shortcode("smile face"));
1878 assert!(!is_valid_shortcode("smile:face"));
1879 assert!(!is_valid_shortcode("😀"));
1880 assert!(!is_valid_shortcode("love~2"));
1883 }
1884
1885 #[test]
1886 fn resolve_token_disambiguates_duplicate_shortcodes() {
1887 let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
1889 by_code.insert(
1890 "love".to_string(),
1891 vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
1892 );
1893 by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
1894
1895 assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
1897 assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
1899 assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
1900 assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
1902 assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
1903 assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
1904 assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
1906 assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
1907 }
1908
1909 #[test]
1910 fn message_emoji_tags_accept_disambiguation_separator() {
1911 let tags = vec![
1913 vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
1914 vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
1915 ];
1916 let out = crate::types::EmojiTag::extract_from_stored(&tags);
1917 assert_eq!(out.len(), 1);
1918 assert_eq!(out[0].shortcode, "love~2");
1919 }
1920}