use std::collections::HashMap;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::state::nostr_client;
const KIND_EMOJI_LIST: u16 = 10030;
pub const KIND_EMOJI_SET: u16 = 30030;
const KIND_EMOJI_SET_ADDR_PREFIX: &str = "30030:";
const _: () = assert!(
KIND_EMOJI_SET == 30030,
"KIND_EMOJI_SET_ADDR_PREFIX literal must match KIND_EMOJI_SET"
);
fn build_pack_addr(pubkey: &str, identifier: &str) -> String {
let mut s = String::with_capacity(
KIND_EMOJI_SET_ADDR_PREFIX.len() + pubkey.len() + 1 + identifier.len(),
);
s.push_str(KIND_EMOJI_SET_ADDR_PREFIX);
s.push_str(pubkey);
s.push(':');
s.push_str(identifier);
s
}
const FETCH_TIMEOUT_SECS: u64 = 20;
const EQUIPPED_PACKS_BY_TIER: [usize; 4] = [3, 6, 9, usize::MAX];
const EMOJIS_PER_PACK_BY_TIER: [usize; 4] = [30, 30, 60, 90];
pub const MAX_EQUIPPED_PACKS: usize = EQUIPPED_PACKS_BY_TIER[0];
pub const MAX_EMOJIS_PER_PACK: usize = EMOJIS_PER_PACK_BY_TIER[0];
pub fn effective_max_equipped_packs() -> usize {
EQUIPPED_PACKS_BY_TIER[crate::badges::effective_tier() as usize]
}
pub fn effective_max_emojis_per_pack() -> usize {
EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
}
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
pub struct PackEmoji {
pub shortcode: String,
pub url: String,
pub sha256: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct EmojiPack {
pub id: String,
pub pubkey: String,
pub identifier: String,
pub title: String,
pub image_url: String,
pub description: String,
pub emojis: Vec<PackEmoji>,
pub is_own: bool,
pub updated_at: u64,
pub status: u8,
}
pub const PACK_STATUS_ACTIVE: u8 = 0;
pub const PACK_STATUS_REVOKED: u8 = 1;
pub const PACK_STATUS_MISSING: u8 = 2;
#[derive(Debug, Clone, PartialEq)]
pub enum PackFetchOutcome {
Found,
Tombstoned,
CleanMiss,
Unreachable,
}
const PACK_MISS_PROMOTE_COUNT: i64 = 3;
const PACK_MISS_PROMOTE_SECS: i64 = 48 * 3600;
const PACK_MISS_RATELIMIT_SECS: i64 = 12 * 3600;
impl EmojiPack {
pub fn addr(&self) -> String {
build_pack_addr(&self.pubkey, &self.identifier)
}
}
fn is_valid_shortcode(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
}
static THEME_EMOJI_TAGS: std::sync::OnceLock<std::sync::Mutex<Vec<(String, String)>>> =
std::sync::OnceLock::new();
fn theme_emoji_tags() -> &'static std::sync::Mutex<Vec<(String, String)>> {
THEME_EMOJI_TAGS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
}
pub fn set_theme_emoji_tags(tags: Vec<(String, String)>) {
if let Ok(mut g) = theme_emoji_tags().lock() {
*g = tags;
}
}
pub fn effective_display_cap() -> usize {
EMOJIS_PER_PACK_BY_TIER[crate::badges::effective_tier() as usize]
}
fn resolve_emoji_token(by_code: &HashMap<String, Vec<String>>, token: &str) -> Option<String> {
if let Some((base, suffix)) = token.rsplit_once('~') {
let n: usize = suffix.parse().ok()?;
if n == 0 { return None; }
return by_code.get(base).and_then(|urls| urls.get(n - 1).cloned());
}
by_code.get(token).and_then(|urls| urls.first().cloned())
}
pub fn resolve_outbound_emoji_tags(content: &str) -> Vec<crate::types::EmojiTag> {
if content.is_empty() || !content.contains(':') {
return Vec::new();
}
let cap = effective_display_cap();
let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
if let Ok(conn) = crate::db::get_db_connection_guard_static() {
if let Ok(mut stmt) = conn.prepare(
"SELECT p.addr, i.shortcode, i.url
FROM emoji_pack_items i
INNER JOIN emoji_packs p ON p.addr = i.pack_addr
INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
WHERE p.status = 0
ORDER BY p.is_own DESC, p.updated_at DESC, i.position ASC"
) {
if let Ok(rows) = stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?))
}) {
let mut per_pack: HashMap<String, usize> = HashMap::new();
for (addr, code, url) in rows.flatten() {
let n = per_pack.entry(addr).or_insert(0);
if *n >= cap { continue; }
*n += 1;
by_code.entry(code).or_default().push(url);
}
}
}
}
if let Ok(theme) = theme_emoji_tags().lock() {
for (code, url) in theme.iter() {
by_code.entry(code.clone()).or_default().push(url.clone());
}
}
if by_code.is_empty() {
return Vec::new();
}
for urls in by_code.values_mut() {
urls.sort();
urls.dedup();
}
let mut out: Vec<crate::types::EmojiTag> = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let bytes = content.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b':' {
let start = i + 1;
let mut j = start;
while j < bytes.len() {
let c = bytes[j];
let ok = c.is_ascii_alphanumeric() || c == b'_' || c == b'-' || c == b'~';
if !ok { break; }
j += 1;
}
if j > start && j < bytes.len() && bytes[j] == b':' {
if let Ok(token) = std::str::from_utf8(&bytes[start..j]) {
if !seen.contains(token) {
if let Some(url) = resolve_emoji_token(&by_code, token) {
out.push(crate::types::EmojiTag {
shortcode: token.to_string(),
url,
});
seen.insert(token.to_string());
}
}
}
i = j + 1;
continue;
}
}
i += 1;
}
out
}
fn first_tag(tags: &Tags, keys: &[&str]) -> Option<String> {
for key in keys {
for tag in tags.iter() {
let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
if parts.len() >= 2 && parts[0] == *key {
return Some(parts[1].to_string());
}
}
}
None
}
pub fn parse_pack_from_event(event: &Event, my_pubkey_hex: Option<&str>) -> Option<EmojiPack> {
if event.kind.as_u16() != KIND_EMOJI_SET {
return None;
}
let identifier = first_tag(&event.tags, &["d"])?;
let pubkey = event.pubkey.to_hex();
let addr = build_pack_addr(&pubkey, &identifier);
let id = match naddr_from_addr(&addr) {
Ok(s) => s,
Err(e) => {
crate::log_warn!(
"[EmojiPacks] naddr encode failed for `{}`: {} — pack dropped",
addr, e,
);
return None;
}
};
let title = first_tag(&event.tags, &["title", "name"]).unwrap_or_default();
let image_url = first_tag(&event.tags, &["image", "picture"]).unwrap_or_default();
let description = first_tag(&event.tags, &["description", "about"]).unwrap_or_default();
let mut emojis = Vec::new();
let mut seen = std::collections::HashSet::new();
for tag in event.tags.iter() {
let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
if parts.len() >= 3 && parts[0] == "emoji" {
let shortcode = parts[1];
if !is_valid_shortcode(shortcode) { continue; }
let url = parts[2].trim();
if url.is_empty() { continue; }
if !seen.insert(shortcode.to_string()) { continue; }
emojis.push(PackEmoji {
shortcode: shortcode.to_string(),
url: url.to_string(),
sha256: None,
});
}
}
if emojis.is_empty() {
return None;
}
let is_own = my_pubkey_hex.map_or(false, |me| me == pubkey);
Some(EmojiPack {
id,
pubkey,
identifier,
title,
image_url,
description,
emojis,
is_own,
updated_at: event.created_at.as_secs(),
status: PACK_STATUS_ACTIVE,
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct PackAddress {
pub kind: u16,
pub pubkey: PublicKey,
pub identifier: String,
}
pub fn parse_pack_address(addr: &str) -> Result<PackAddress, String> {
let mut parts = addr.splitn(3, ':');
let kind_str = parts.next().ok_or_else(|| "missing kind".to_string())?;
let pubkey_str = parts.next().ok_or_else(|| "missing pubkey".to_string())?;
let identifier = parts.next().ok_or_else(|| "missing identifier".to_string())?;
let kind: u16 = kind_str.parse()
.map_err(|_| format!("invalid kind: {}", kind_str))?;
if kind != KIND_EMOJI_SET {
return Err(format!("expected kind {}, got {}", KIND_EMOJI_SET, kind));
}
let pubkey = PublicKey::from_hex(pubkey_str)
.map_err(|e| format!("invalid pubkey: {}", e))?;
Ok(PackAddress { kind, pubkey, identifier: identifier.to_string() })
}
impl PackAddress {
pub fn to_addr_string(&self) -> String {
debug_assert_eq!(self.kind, KIND_EMOJI_SET,
"PackAddress kind mismatch — was it constructed bypassing parse_pack_address?");
build_pack_addr(&self.pubkey.to_hex(), &self.identifier)
}
}
pub fn naddr_from_addr(addr: &str) -> Result<String, String> {
let parsed = parse_pack_address(addr)?;
let coord = nostr_sdk::nips::nip01::Coordinate {
kind: Kind::Custom(parsed.kind),
public_key: parsed.pubkey,
identifier: parsed.identifier,
};
let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
coordinate: coord,
relays: Vec::new(),
};
nostr_sdk::nips::nip19::Nip19::Coordinate(n19)
.to_bech32()
.map_err(|e| format!("encode naddr: {}", e))
}
pub fn parse_naddr(naddr: &str) -> Result<PackAddress, String> {
let trimmed = naddr.trim().trim_start_matches("nostr:");
let parsed = nostr_sdk::nips::nip19::Nip19::from_bech32(trimmed)
.map_err(|e| format!("invalid naddr: {}", e))?;
let coord = match parsed {
nostr_sdk::nips::nip19::Nip19::Coordinate(c) => c,
_ => return Err("naddr expected (Nip19 was not a coordinate)".to_string()),
};
let kind = coord.kind.as_u16();
if kind != KIND_EMOJI_SET {
return Err(format!(
"expected kind {} (emoji set), got {}",
KIND_EMOJI_SET, kind,
));
}
Ok(PackAddress {
kind,
pubkey: coord.public_key,
identifier: coord.identifier.clone(),
})
}
const THEME_SLOT_TOKEN: &str = "theme_slot";
fn parse_inner_tag_list(plaintext: &str) -> Vec<PackAddress> {
let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
Ok(v) => v,
Err(e) => {
crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
return Vec::new();
}
};
inner.into_iter()
.filter_map(|tup| {
if tup.len() >= 2 && tup[0] == "a" {
parse_pack_address(&tup[1]).ok()
} else {
None
}
})
.collect()
}
fn parse_inner_tag_list_with_anchor(plaintext: &str) -> (Vec<String>, Option<String>) {
let inner: Vec<Vec<String>> = match serde_json::from_str(plaintext) {
Ok(v) => v,
Err(e) => {
crate::log_warn!("[EmojiPacks] emoji list JSON parse failed: {}", e);
return (Vec::new(), None);
}
};
let mut addrs: Vec<String> = Vec::new();
let mut anchor: Option<String> = None;
for tup in inner {
if tup.len() >= 2 && tup[0] == "a" {
if let Ok(pa) = parse_pack_address(&tup[1]) {
addrs.push(pa.to_addr_string());
}
} else if anchor.is_none() && tup.first().map(String::as_str) == Some(THEME_SLOT_TOKEN) {
anchor = Some(addrs.last().cloned().unwrap_or_default());
}
}
(addrs, anchor)
}
pub async fn decrypt_subscribed_addresses(
client: &Client,
my_pk: &PublicKey,
event: &Event,
) -> Vec<PackAddress> {
match decrypt_emoji_list_plaintext(client, my_pk, event).await {
Some(plaintext) => parse_inner_tag_list(&plaintext),
None => Vec::new(),
}
}
async fn decrypt_emoji_list_plaintext(
client: &Client,
my_pk: &PublicKey,
event: &Event,
) -> Option<String> {
if event.content.is_empty() {
return None;
}
let signer = match client.signer().await {
Ok(s) => s,
Err(e) => {
crate::log_warn!("[EmojiPacks] signer unavailable for emoji list decrypt: {}", e);
return None;
}
};
match signer.nip44_decrypt(my_pk, &event.content).await {
Ok(p) => Some(p),
Err(e) => {
crate::log_warn!("[EmojiPacks] emoji list decrypt failed: {}", e);
None
}
}
}
async fn decrypt_subscribed_addresses_with_anchor(
client: &Client,
my_pk: &PublicKey,
event: &Event,
) -> (Vec<PackAddress>, Option<String>) {
match decrypt_emoji_list_plaintext(client, my_pk, event).await {
Some(plaintext) => {
let (raw, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
let addrs = raw.iter().filter_map(|s| parse_pack_address(s).ok()).collect();
(addrs, anchor)
}
None => (Vec::new(), None),
}
}
pub fn save_pack(pack: &EmojiPack) -> Result<(), String> {
let mut conn = crate::db::get_write_connection_guard_static()?;
let tx = conn.transaction()
.map_err(|e| format!("Failed to start tx: {}", e))?;
let addr = pack.addr();
tx.execute(
"INSERT OR REPLACE INTO emoji_packs
(addr, pubkey, identifier, title, image_url, description, is_own, updated_at, raw_event)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, '')",
rusqlite::params![
addr, pack.pubkey, pack.identifier,
pack.title, pack.image_url, pack.description,
pack.is_own as i32, pack.updated_at as i64,
],
).map_err(|e| format!("Failed to upsert pack: {}", e))?;
tx.execute(
"DELETE FROM emoji_pack_items WHERE pack_addr = ?1",
rusqlite::params![addr],
).map_err(|e| format!("Failed to clear pack items: {}", e))?;
for (pos, emoji) in pack.emojis.iter().enumerate() {
tx.execute(
"INSERT INTO emoji_pack_items (pack_addr, shortcode, url, sha256, position)
VALUES (?1, ?2, ?3, ?4, ?5)",
rusqlite::params![
addr, emoji.shortcode, emoji.url, emoji.sha256, pos as i64,
],
).map_err(|e| format!("Failed to insert pack item: {}", e))?;
}
tx.commit().map_err(|e| format!("Failed to commit pack: {}", e))?;
Ok(())
}
pub fn apply_pack_health(addr: &str, outcome: &PackFetchOutcome, now: i64) -> Result<bool, String> {
use rusqlite::OptionalExtension;
if matches!(outcome, PackFetchOutcome::Unreachable) {
return Ok(false);
}
let mut conn = crate::db::get_write_connection_guard_static()?;
let tx = conn
.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
.map_err(|e| format!("begin pack health tx: {}", e))?;
let row = tx
.query_row(
"SELECT status, miss_count, first_missed_at, last_miss_counted_at
FROM emoji_packs WHERE addr = ?1",
rusqlite::params![addr],
|r| {
Ok((
r.get::<_, i64>(0)?,
r.get::<_, i64>(1)?,
r.get::<_, i64>(2)?,
r.get::<_, i64>(3)?,
))
},
)
.optional()
.map_err(|e| format!("read pack health: {}", e))?;
let Some((status, miss_count, first_missed_at, last_miss_counted_at)) = row else {
return Ok(false);
};
let (new_status, new_miss, new_first, new_last) = match outcome {
PackFetchOutcome::Found => (PACK_STATUS_ACTIVE as i64, 0, 0, 0),
PackFetchOutcome::Tombstoned => (PACK_STATUS_REVOKED as i64, 0, 0, 0),
PackFetchOutcome::CleanMiss => {
if status == PACK_STATUS_REVOKED as i64 {
(status, miss_count, first_missed_at, last_miss_counted_at)
} else if now - last_miss_counted_at < PACK_MISS_RATELIMIT_SECS {
(status, miss_count, first_missed_at, last_miss_counted_at)
} else {
let first = if miss_count == 0 { now } else { first_missed_at };
let count = miss_count + 1;
let promoted = count >= PACK_MISS_PROMOTE_COUNT
&& now - first >= PACK_MISS_PROMOTE_SECS;
(
if promoted { PACK_STATUS_MISSING as i64 } else { status },
count,
first,
now,
)
}
}
PackFetchOutcome::Unreachable => unreachable!(),
};
let changed = new_status != status;
tx.execute(
"UPDATE emoji_packs SET status = ?2, miss_count = ?3, first_missed_at = ?4,
last_miss_counted_at = ?5,
status_changed_at = CASE WHEN status != ?2 THEN ?6 ELSE status_changed_at END
WHERE addr = ?1",
rusqlite::params![addr, new_status, new_miss, new_first, new_last, now],
)
.map_err(|e| format!("write pack health: {}", e))?;
if changed && new_status != PACK_STATUS_ACTIVE as i64 {
tx.execute(
"DELETE FROM emoji_usage WHERE kind = 1 AND url IN
(SELECT url FROM emoji_pack_items WHERE pack_addr = ?1)",
rusqlite::params![addr],
)
.map_err(|e| format!("purge dead pack usage: {}", e))?;
}
tx.commit().map_err(|e| format!("commit pack health: {}", e))?;
Ok(changed)
}
pub fn save_subscriptions(addrs: &[String]) -> Result<(), String> {
let mut conn = crate::db::get_write_connection_guard_static()?;
let tx = conn.transaction()
.map_err(|e| format!("Failed to start tx: {}", e))?;
tx.execute("DELETE FROM emoji_pack_subscriptions", [])
.map_err(|e| format!("Failed to clear subscriptions: {}", e))?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64;
for (pos, addr) in addrs.iter().enumerate() {
tx.execute(
"INSERT OR REPLACE INTO emoji_pack_subscriptions (addr, subscribed_at, position)
VALUES (?1, ?2, ?3)",
rusqlite::params![addr, now, pos as i64],
).map_err(|e| format!("Failed to insert subscription: {}", e))?;
}
tx.commit().map_err(|e| format!("Failed to commit subscriptions: {}", e))?;
Ok(())
}
pub fn load_subscriptions() -> Result<Vec<String>, String> {
let conn = crate::db::get_db_connection_guard_static()?;
let mut stmt = conn.prepare("SELECT addr FROM emoji_pack_subscriptions ORDER BY position ASC, subscribed_at ASC")
.map_err(|e| format!("prepare: {}", e))?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0))
.map_err(|e| format!("query: {}", e))?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(|e| format!("row: {}", e))?);
}
Ok(out)
}
pub fn load_cached_pack(addr: &str) -> Result<Option<EmojiPack>, String> {
let conn = crate::db::get_db_connection_guard_static()?;
let mut pack = match conn.query_row(
"SELECT pubkey, identifier, title, image_url, description, is_own, updated_at, status
FROM emoji_packs WHERE addr = ?1",
rusqlite::params![addr],
|row| {
Ok(EmojiPack {
id: naddr_from_addr(addr).unwrap_or_else(|_| addr.to_string()),
pubkey: row.get(0)?,
identifier: row.get(1)?,
title: row.get(2)?,
image_url: row.get(3)?,
description: row.get(4)?,
is_own: row.get::<_, i32>(5)? != 0,
updated_at: row.get::<_, i64>(6)? as u64,
emojis: Vec::new(),
status: row.get::<_, i64>(7)? as u8,
})
},
) {
Ok(p) => p,
Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
Err(e) => return Err(format!("query cached pack: {}", e)),
};
let mut stmt = conn.prepare(
"SELECT shortcode, url, sha256 FROM emoji_pack_items
WHERE pack_addr = ?1 ORDER BY position ASC"
).map_err(|e| format!("prepare items: {}", e))?;
let rows = stmt.query_map(rusqlite::params![addr], |row| {
Ok(PackEmoji {
shortcode: row.get(0)?,
url: row.get(1)?,
sha256: row.get(2)?,
})
}).map_err(|e| format!("query items: {}", e))?;
for r in rows {
pack.emojis.push(r.map_err(|e| format!("row item: {}", e))?);
}
Ok(Some(pack))
}
pub fn load_all_packs() -> Result<Vec<EmojiPack>, String> {
let conn = crate::db::get_db_connection_guard_static()?;
let mut packs: HashMap<String, EmojiPack> = HashMap::new();
let mut order: Vec<String> = Vec::new();
{
let mut stmt = conn.prepare(
"SELECT p.addr, p.pubkey, p.identifier, p.title, p.image_url, p.description, p.is_own, p.updated_at, p.status
FROM emoji_packs p
INNER JOIN emoji_pack_subscriptions s ON s.addr = p.addr
ORDER BY s.position ASC"
).map_err(|e| format!("prepare packs: {}", e))?;
let rows = stmt.query_map([], |row| {
let raw_addr: String = row.get(0)?;
let id = naddr_from_addr(&raw_addr).unwrap_or(raw_addr.clone());
Ok((raw_addr, EmojiPack {
id,
pubkey: row.get(1)?,
identifier: row.get(2)?,
title: row.get(3)?,
image_url: row.get(4)?,
description: row.get(5)?,
is_own: row.get::<_, i32>(6)? != 0,
updated_at: row.get::<_, i64>(7)? as u64,
emojis: Vec::new(),
status: row.get::<_, i64>(8)? as u8,
}))
}).map_err(|e| format!("query packs: {}", e))?;
for r in rows {
let (raw_addr, pack) = r.map_err(|e| format!("row pack: {}", e))?;
order.push(raw_addr.clone());
packs.insert(raw_addr, pack);
}
}
{
let mut stmt = conn.prepare(
"SELECT pack_addr, shortcode, url, sha256
FROM emoji_pack_items
ORDER BY pack_addr, position ASC"
).map_err(|e| format!("prepare items: {}", e))?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
PackEmoji {
shortcode: row.get(1)?,
url: row.get(2)?,
sha256: row.get(3)?,
},
))
}).map_err(|e| format!("query items: {}", e))?;
for r in rows {
let (addr, emoji) = r.map_err(|e| format!("row item: {}", e))?;
if let Some(p) = packs.get_mut(&addr) {
p.emojis.push(emoji);
}
}
}
Ok(order.into_iter().filter_map(|a| packs.remove(&a)).collect())
}
const NIP65_CACHE_TTL_SECS: u64 = 3600;
const NIP65_CACHE_TTL_ERROR_SECS: u64 = 60;
const NIP65_FETCH_TIMEOUT_SECS: u64 = 10;
#[derive(Clone)]
struct CachedRelayList {
relays: Vec<RelayUrl>,
fetched_at: std::time::Instant,
empty: bool,
verified: bool,
}
static NIP65_CACHE: std::sync::OnceLock<std::sync::RwLock<HashMap<PublicKey, CachedRelayList>>> =
std::sync::OnceLock::new();
fn nip65_cache() -> &'static std::sync::RwLock<HashMap<PublicKey, CachedRelayList>> {
NIP65_CACHE.get_or_init(|| std::sync::RwLock::new(HashMap::new()))
}
fn cached_write_relays(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
let cache = nip65_cache().read().ok()?;
let entry = cache.get(pubkey)?;
let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
Some(entry.relays.clone())
} else {
None
}
}
fn cached_write_relays_verified(pubkey: &PublicKey) -> Option<Vec<RelayUrl>> {
let cache = nip65_cache().read().ok()?;
let entry = cache.get(pubkey)?;
if !entry.verified {
return None;
}
let ttl = if entry.empty { NIP65_CACHE_TTL_ERROR_SECS } else { NIP65_CACHE_TTL_SECS };
if entry.fetched_at.elapsed() < std::time::Duration::from_secs(ttl) {
Some(entry.relays.clone())
} else {
None
}
}
pub fn clear_nip65_cache() {
if let Ok(mut cache) = nip65_cache().write() {
cache.clear();
}
}
fn cache_write_relays(pubkey: PublicKey, relays: Vec<RelayUrl>, verified: bool) {
if let Ok(mut cache) = nip65_cache().write() {
let empty = relays.is_empty();
cache.insert(pubkey, CachedRelayList {
relays,
fetched_at: std::time::Instant::now(),
empty,
verified,
});
}
}
fn extract_write_relays(ev: &Event) -> Vec<RelayUrl> {
let mut relays: Vec<RelayUrl> = Vec::new();
for (url, marker) in nostr_sdk::nips::nip65::extract_relay_list(ev) {
match marker {
None | Some(nostr_sdk::nips::nip65::RelayMetadata::Write) => {
if !relays.contains(url) {
relays.push(url.clone());
}
}
Some(nostr_sdk::nips::nip65::RelayMetadata::Read) => {}
}
}
relays
}
async fn fetch_author_write_relays(client: &Client, pubkey: PublicKey) -> Vec<RelayUrl> {
if let Some(relays) = cached_write_relays(&pubkey) {
return relays;
}
let filter = Filter::new()
.author(pubkey)
.kind(Kind::RelayList)
.limit(1);
let events = match client
.fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
.await
{
Ok(evs) => evs,
Err(_) => {
cache_write_relays(pubkey, Vec::new(), false);
return Vec::new();
}
};
let relays = events.into_iter()
.max_by_key(|e| e.created_at)
.map(|ev| extract_write_relays(&ev))
.unwrap_or_default();
cache_write_relays(pubkey, relays.clone(), true);
relays
}
async fn prefetch_author_write_relays(client: &Client, authors: &[PublicKey]) {
let uncached: Vec<PublicKey> = authors.iter()
.filter(|pk| cached_write_relays(pk).is_none())
.copied()
.collect();
if uncached.is_empty() {
return;
}
let filter = Filter::new()
.authors(uncached.iter().copied())
.kind(Kind::RelayList);
let events = match client
.fetch_events(filter, std::time::Duration::from_secs(NIP65_FETCH_TIMEOUT_SECS))
.await
{
Ok(evs) => evs,
Err(_) => return,
};
let mut newest: HashMap<PublicKey, Event> = HashMap::new();
for ev in events {
match newest.get(&ev.pubkey) {
Some(existing) if existing.created_at >= ev.created_at => {}
_ => { newest.insert(ev.pubkey, ev); }
}
}
for pk in uncached {
let relays = newest.get(&pk).map(extract_write_relays).unwrap_or_default();
cache_write_relays(pk, relays, true);
}
}
async fn fetch_pack_from_relays(client: &Client, addr: &PackAddress) -> Option<EmojiPack> {
let filter = Filter::new()
.author(addr.pubkey)
.kind(Kind::Custom(KIND_EMOJI_SET))
.identifier(&addr.identifier)
.limit(1);
let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
let me = crate::state::my_public_key().map(|pk| pk.to_hex());
match client.fetch_events(filter.clone(), timeout).await {
Ok(events) => {
if let Some(ev) = events.into_iter().max_by_key(|e| e.created_at) {
if let Some(pack) = parse_pack_from_event(&ev, me.as_deref()) {
return Some(pack);
}
}
}
Err(e) => crate::log_warn!("[EmojiPacks] home fetch {} failed: {}", &addr.identifier, e),
}
let outbox = fetch_author_write_relays(client, addr.pubkey).await;
if outbox.is_empty() {
return None;
}
fetch_pack_via_isolated_client(&outbox, filter, timeout, me.as_deref()).await
}
async fn fetch_pack_via_isolated_client(
relays: &[RelayUrl],
filter: Filter,
timeout: std::time::Duration,
my_pubkey_hex: Option<&str>,
) -> Option<EmojiPack> {
let scratch = ClientBuilder::new()
.opts(crate::nostr_client_options())
.build();
for r in relays {
let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
let _ = scratch.pool().add_relay(r.as_str(), opts).await;
}
scratch.connect().await;
let result = scratch.fetch_events(filter, timeout).await;
scratch.shutdown().await;
let events = match result {
Ok(events) => events,
Err(e) => {
crate::log_warn!("[EmojiPacks] outbox fetch failed: {}", e);
return None;
}
};
let event = events.into_iter().max_by_key(|e| e.created_at)?;
parse_pack_from_event(&event, my_pubkey_hex)
}
fn event_coord(ev: &Event) -> Option<String> {
let d = first_tag(&ev.tags, &["d"])?;
Some(format!("{}:{}", ev.pubkey.to_hex(), d))
}
fn addr_coord(addr: &PackAddress) -> String {
format!("{}:{}", addr.pubkey.to_hex(), addr.identifier)
}
fn newest_events_by_coord(
events: impl IntoIterator<Item = Event>,
wanted: &std::collections::HashSet<String>,
) -> HashMap<String, Event> {
let mut newest: HashMap<String, Event> = HashMap::new();
for ev in events {
if ev.kind.as_u16() != KIND_EMOJI_SET { continue; }
let Some(coord) = event_coord(&ev) else { continue; };
if !wanted.contains(&coord) { continue; }
match newest.get(&coord) {
Some(existing) if existing.created_at >= ev.created_at => {}
_ => { newest.insert(coord, ev); }
}
}
newest
}
fn merge_newest(acc: &mut HashMap<String, Event>, phase: HashMap<String, Event>) {
for (coord, ev) in phase {
match acc.get(&coord) {
Some(existing) if existing.created_at >= ev.created_at => {}
_ => { acc.insert(coord, ev); }
}
}
}
fn deletion_filter(addrs: &[&PackAddress]) -> Filter {
Filter::new()
.authors(addrs.iter().map(|a| a.pubkey))
.kind(Kind::EventDeletion)
.custom_tags(
SingleLetterTag::lowercase(Alphabet::A),
addrs.iter().map(|a| a.to_addr_string()),
)
}
fn deletion_matches(ev: &Event, author: &PublicKey, raw_addr: &str) -> bool {
ev.kind == Kind::EventDeletion
&& ev.pubkey == *author
&& ev.tags.iter().any(|t| {
let s = t.as_slice();
s.len() >= 2 && s[0] == "a" && s[1] == raw_addr
})
}
async fn connected_read_relays(client: &Client, read_only: bool) -> std::collections::HashSet<String> {
client
.relays()
.await
.iter()
.filter(|(_, r)| r.status() == RelayStatus::Connected && (!read_only || r.flags().has_read()))
.map(|(url, _)| url.to_string())
.collect()
}
fn absence_is_clean(
home_ok: bool,
live_relays: usize,
author_writes: Option<&Vec<RelayUrl>>,
outbox_ok: bool,
outbox_live: usize,
) -> bool {
if !home_ok || live_relays < 2 {
return false;
}
match author_writes {
None => false,
Some(w) if w.is_empty() => true,
Some(_) => outbox_ok && outbox_live >= 1,
}
}
fn classify_pack(
newest_ev: Option<&Event>,
newest_deletion: Option<&Event>,
me: Option<&str>,
absence_clean: bool,
) -> (PackFetchOutcome, Option<EmojiPack>) {
match newest_ev {
Some(ev) => match parse_pack_from_event(ev, me) {
Some(pack) => {
match newest_deletion {
Some(del) if del.created_at > ev.created_at => {
(PackFetchOutcome::Tombstoned, None)
}
_ => (PackFetchOutcome::Found, Some(pack)),
}
}
None => {
let has_emoji_tags = ev.tags.iter().any(|t| {
t.as_slice().first().map(|k| k == "emoji").unwrap_or(false)
});
if has_emoji_tags {
(PackFetchOutcome::Unreachable, None)
} else {
(PackFetchOutcome::Tombstoned, None)
}
}
},
None if newest_deletion.is_some() => (PackFetchOutcome::Tombstoned, None),
None if absence_clean => (PackFetchOutcome::CleanMiss, None),
None => (PackFetchOutcome::Unreachable, None),
}
}
struct PackSweep {
packs: Vec<EmojiPack>,
outcomes: HashMap<String, PackFetchOutcome>,
}
async fn sweep_packs_from_relays(client: &Client, addrs: &[PackAddress]) -> PackSweep {
if addrs.is_empty() {
return PackSweep { packs: Vec::new(), outcomes: HashMap::new() };
}
let timeout = std::time::Duration::from_secs(FETCH_TIMEOUT_SECS);
let me = crate::state::my_public_key().map(|pk| pk.to_hex());
let wanted: std::collections::HashSet<String> = addrs.iter().map(addr_coord).collect();
let all_refs: Vec<&PackAddress> = addrs.iter().collect();
let home_before = connected_read_relays(client, true).await;
let home_filter = Filter::new()
.authors(addrs.iter().map(|a| a.pubkey))
.kind(Kind::Custom(KIND_EMOJI_SET))
.identifiers(addrs.iter().map(|a| a.identifier.clone()));
let mut newest: HashMap<String, Event> = HashMap::new();
let mut deletions: Vec<Event> = Vec::new();
let mut home_ok = false;
match client.fetch_events(home_filter, timeout).await {
Ok(events) => {
home_ok = true;
merge_newest(&mut newest, newest_events_by_coord(events, &wanted));
}
Err(e) => {
crate::log_warn!("[EmojiPacks] batched home fetch failed: {}", e);
}
}
if home_ok {
match client.fetch_events(deletion_filter(&all_refs), timeout).await {
Ok(events) => deletions.extend(events),
Err(e) => crate::log_warn!("[EmojiPacks] home deletion fetch failed: {}", e),
}
}
let home_after = connected_read_relays(client, true).await;
let mut live_relays: std::collections::HashSet<String> =
home_before.intersection(&home_after).cloned().collect();
let misses: Vec<&PackAddress> = addrs.iter()
.filter(|a| !newest.contains_key(&addr_coord(a)))
.collect();
let mut outbox_ok = false;
let mut outbox_live = 0usize;
if !misses.is_empty() {
let miss_authors: Vec<PublicKey> = {
let mut v: Vec<PublicKey> = misses.iter().map(|a| a.pubkey).collect();
v.sort(); v.dedup();
v
};
prefetch_author_write_relays(client, &miss_authors).await;
let mut outbox: Vec<RelayUrl> = Vec::new();
for pk in &miss_authors {
for r in cached_write_relays(pk).unwrap_or_default() {
if !outbox.contains(&r) { outbox.push(r); }
}
}
if !outbox.is_empty() {
let miss_filter = Filter::new()
.authors(misses.iter().map(|a| a.pubkey))
.kind(Kind::Custom(KIND_EMOJI_SET))
.identifiers(misses.iter().map(|a| a.identifier.clone()));
let wanted_misses: std::collections::HashSet<String> =
misses.iter().map(|a| addr_coord(a)).collect();
if let Some((events, dels, live)) = sweep_via_isolated_client(
&outbox, miss_filter, deletion_filter(&misses), timeout,
).await {
outbox_ok = true;
outbox_live = live.len();
live_relays.extend(live);
merge_newest(&mut newest, newest_events_by_coord(events, &wanted_misses));
deletions.extend(dels);
}
}
}
let mut packs: Vec<EmojiPack> = Vec::new();
let mut outcomes: HashMap<String, PackFetchOutcome> = HashMap::new();
for addr in addrs {
let coord = addr_coord(addr);
let raw_addr = addr.to_addr_string();
let newest_deletion = deletions.iter()
.filter(|d| deletion_matches(d, &addr.pubkey, &raw_addr))
.max_by_key(|d| d.created_at);
let author_writes = cached_write_relays_verified(&addr.pubkey);
let clean = absence_is_clean(
home_ok,
live_relays.len(),
author_writes.as_ref(),
outbox_ok,
outbox_live,
);
let (outcome, pack) = classify_pack(
newest.get(&coord),
newest_deletion,
me.as_deref(),
clean,
);
if let Some(p) = pack {
packs.push(p);
}
outcomes.insert(raw_addr, outcome);
}
PackSweep { packs, outcomes }
}
async fn sweep_via_isolated_client(
relays: &[RelayUrl],
pack_filter: Filter,
del_filter: Filter,
timeout: std::time::Duration,
) -> Option<(Vec<Event>, Vec<Event>, std::collections::HashSet<String>)> {
let scratch = ClientBuilder::new()
.opts(crate::nostr_client_options())
.build();
for r in relays {
let opts = crate::tor_aware_relay_options(RelayOptions::new().reconnect(false));
let _ = scratch.pool().add_relay(r.as_str(), opts).await;
}
scratch.connect().await;
let connect_deadline = std::time::Instant::now() + std::time::Duration::from_secs(8);
let before = loop {
let connected = connected_read_relays(&scratch, false).await;
if !connected.is_empty() || std::time::Instant::now() >= connect_deadline {
break connected;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
};
let result = scratch.fetch_events(pack_filter, timeout).await;
let dels = match &result {
Ok(_) => scratch.fetch_events(del_filter, timeout).await.ok(),
Err(_) => None,
};
let after = connected_read_relays(&scratch, false).await;
scratch.shutdown().await;
match result {
Ok(events) => Some((
events.into_iter().collect(),
dels.map(|d| d.into_iter().collect()).unwrap_or_default(),
before.intersection(&after).cloned().collect(),
)),
Err(e) => {
crate::log_warn!("[EmojiPacks] batched outbox fetch failed: {}", e);
None
}
}
}
pub async fn fetch_subscribed_packs(
client: &Client,
my_pubkey: PublicKey,
session: crate::state::SessionGuard,
) -> Result<Vec<EmojiPack>, String> {
let list_filter = Filter::new()
.author(my_pubkey)
.kind(Kind::Custom(KIND_EMOJI_LIST))
.limit(1);
let list_events = client
.fetch_events(list_filter, std::time::Duration::from_secs(FETCH_TIMEOUT_SECS))
.await
.map_err(|e| format!("fetch kind 10030: {}", e))?;
if !session.is_valid() {
return Ok(Vec::new());
}
let local_addrs = || -> Vec<PackAddress> {
load_subscriptions()
.unwrap_or_default()
.into_iter()
.filter_map(|s| parse_pack_address(&s).ok())
.collect()
};
let our_last_publish: u64 = crate::db::settings::get_sql_setting(EMOJI_LIST_PUBLISHED_AT_KEY.to_string())
.ok()
.flatten()
.and_then(|s| s.parse().ok())
.unwrap_or(0);
let list_event = list_events.into_iter().max_by_key(|e| e.created_at);
let mut fetched_anchor: Option<String> = None;
let addrs: Vec<PackAddress> = match list_event {
Some(ev) if ev.created_at.as_secs() < our_last_publish => {
crate::log_debug!(
"[EmojiPacks] fetched kind 10030 (created_at {}) predates our publish ({}) — keeping local subs",
ev.created_at.as_secs(), our_last_publish,
);
local_addrs()
}
Some(ev) => {
let (a, anchor) = decrypt_subscribed_addresses_with_anchor(client, &my_pubkey, &ev).await;
fetched_anchor = anchor;
a
}
None => {
crate::log_debug!(
"[EmojiPacks] kind 10030 not on relays — refreshing local subs only",
);
local_addrs()
}
};
let addr_strings: Vec<String> = addrs.iter().map(|a| a.to_addr_string()).collect();
let PackSweep { packs: fresh, outcomes } = sweep_packs_from_relays(client, &addrs).await;
let tombstoned = outcomes.values().filter(|o| matches!(o, PackFetchOutcome::Tombstoned)).count();
let unresolved = addrs.len().saturating_sub(fresh.len()).saturating_sub(tombstoned);
if unresolved > 0 {
crate::log_warn!(
"[EmojiPacks] {} subscribed pack(s) not on relays — keeping cached copies",
unresolved,
);
}
if tombstoned > 0 {
crate::log_info!("[EmojiPacks] {} subscribed pack(s) deleted by their creator", tombstoned);
}
if !session.is_valid() {
return Ok(fresh);
}
let me_hex = my_pubkey.to_hex();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let mut health_changed = false;
for addr in &addrs {
if addr.pubkey.to_hex() == me_hex { continue; }
let raw = addr.to_addr_string();
let Some(outcome) = outcomes.get(&raw) else { continue; };
match apply_pack_health(&raw, outcome, now) {
Ok(true) => {
health_changed = true;
crate::log_info!(
"[EmojiPacks] pack `{}` health changed ({:?})",
addr.identifier, outcome,
);
}
Ok(false) => {}
Err(e) => crate::log_warn!("[EmojiPacks] health update for `{}` failed: {}", addr.identifier, e),
}
}
for pack in &fresh {
if let Err(e) = save_pack(pack) {
crate::log_warn!("[EmojiPacks] save pack {} failed: {}", pack.identifier, e);
}
}
let list_changed = load_subscriptions().unwrap_or_default() != addr_strings
|| fetched_anchor.as_deref().map_or(false, |a| a != load_theme_slot_anchor());
if let Err(e) = save_subscriptions(&addr_strings) {
crate::log_warn!("[EmojiPacks] save subscriptions failed: {}", e);
}
if let Some(anchor) = fetched_anchor {
if let Err(e) = save_theme_slot_anchor(&anchor) {
crate::log_warn!("[EmojiPacks] save theme slot anchor failed: {}", e);
}
}
if health_changed || list_changed {
crate::traits::emit_event("emoji_packs_updated", &());
}
crate::log_info!(
"[EmojiPacks] Resolved {} of {} subscribed pack(s){}",
fresh.len(),
addrs.len(),
if unresolved > 0 {
format!(" ({} via cache)", unresolved)
} else {
String::new()
},
);
load_all_packs()
}
pub async fn refresh_subscribed_packs() -> Result<Vec<EmojiPack>, String> {
let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
let me = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
let session = crate::state::SessionGuard::capture();
fetch_subscribed_packs(&client, me, session).await
}
pub async fn fetch_pack_by_naddr(naddr: &str) -> Result<EmojiPack, String> {
let addr = parse_naddr(naddr)?;
let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
fetch_pack_from_relays(&client, &addr).await
.ok_or_else(|| {
crate::log_debug!("[EmojiPacks] preview miss: {}:{}", addr.pubkey.to_hex(), addr.identifier);
"Pack not found on any relay".to_string()
})
}
pub async fn get_or_fetch_theme_pack(naddr: &str) -> Result<Option<EmojiPack>, String> {
let addr = parse_naddr(naddr)?;
let coord = addr.to_addr_string();
if let Some(cached) = load_cached_pack(&coord)? {
let naddr_owned = naddr.to_string();
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
if !session.is_valid() { return; }
let Some(client) = nostr_client() else { return };
if let Ok(parsed) = parse_naddr(&naddr_owned) {
if let Some(fresh) = fetch_pack_from_relays(&client, &parsed).await {
if session.is_valid() && fresh.updated_at > cached.updated_at {
if let Err(e) = save_pack(&fresh) {
crate::log_warn!("[EmojiPacks] theme pack refresh save failed: {}", e);
} else {
crate::traits::emit_event("emoji_packs_updated", &());
}
}
}
}
});
return Ok(Some(cached));
}
let session = crate::state::SessionGuard::capture();
let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
match fetch_pack_from_relays(&client, &addr).await {
Some(pack) => {
if session.is_valid() {
if let Err(e) = save_pack(&pack) {
crate::log_warn!("[EmojiPacks] theme pack cache save failed: {}", e);
}
}
Ok(Some(pack))
}
None => Ok(None),
}
}
const THEME_SLOT_ANCHOR_KEY: &str = "emoji_theme_slot_anchor";
fn load_theme_slot_anchor() -> String {
crate::db::settings::get_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string())
.ok()
.flatten()
.unwrap_or_default()
}
fn save_theme_slot_anchor(raw_addr: &str) -> Result<(), String> {
crate::db::settings::set_sql_setting(THEME_SLOT_ANCHOR_KEY.to_string(), raw_addr.to_string())
}
fn build_inner_tags_with_marker(addrs: &[String], anchor: &str) -> Vec<Vec<String>> {
let mut inner: Vec<Vec<String>> = Vec::with_capacity(addrs.len() + 1);
let mut placed = false;
if anchor.is_empty() {
inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
placed = true;
}
for addr in addrs {
inner.push(vec!["a".to_string(), addr.clone()]);
if !placed && addr == anchor {
inner.push(vec![THEME_SLOT_TOKEN.to_string()]);
placed = true;
}
}
if !placed {
inner.insert(0, vec![THEME_SLOT_TOKEN.to_string()]);
}
inner
}
pub async fn publish_emoji_list(client: &Client) -> Result<(), String> {
let addrs = load_subscriptions()?;
let anchor = load_theme_slot_anchor();
let my_pk = crate::state::my_public_key()
.ok_or_else(|| "Not logged in".to_string())?;
let inner_tags = build_inner_tags_with_marker(&addrs, &anchor);
let plaintext = serde_json::to_string(&inner_tags)
.map_err(|e| format!("Serialise emoji list: {}", e))?;
let signer = client.signer().await
.map_err(|e| format!("Signer unavailable: {}", e))?;
let content = signer.nip44_encrypt(&my_pk, &plaintext).await
.map_err(|e| format!("nip44 encrypt emoji list: {}", e))?;
let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_LIST), content);
client.send_event_builder(builder).await
.map_err(|e| format!("Failed to publish emoji list (kind 10030): {}", e))?;
crate::log_info!("[EmojiPacks] Published encrypted kind 10030 with {} pack subscription(s)", addrs.len());
Ok(())
}
const EMOJI_LIST_PUBLISHED_AT_KEY: &str = "emoji_list_published_at";
static REPUBLISH_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
pub fn republish_emoji_list_debounced() {
use std::sync::atomic::Ordering;
let _ = crate::db::settings::set_sql_setting(
EMOJI_LIST_PUBLISHED_AT_KEY.to_string(),
Timestamp::now().as_secs().to_string(),
);
let gen = REPUBLISH_GEN.fetch_add(1, Ordering::SeqCst) + 1;
let session = crate::state::SessionGuard::capture();
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(800)).await;
if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
if !session.is_valid() { return; }
let client = match nostr_client() {
Some(c) => c,
None => return,
};
if let Err(e) = publish_emoji_list(&client).await {
crate::log_warn!("[EmojiPacks] Republish failed: {} (retrying in 5s)", e);
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if REPUBLISH_GEN.load(Ordering::SeqCst) != gen { return; }
if !session.is_valid() { return; }
if let Err(e2) = publish_emoji_list(&client).await {
crate::log_warn!("[EmojiPacks] Republish retry failed: {}", e2);
}
}
});
}
pub async fn subscribe_pack(naddr: &str) -> Result<EmojiPack, String> {
let session = crate::state::SessionGuard::capture();
let pack = fetch_pack_by_naddr(naddr).await?;
if !session.is_valid() {
return Err("Account swapped during fetch — aborted".to_string());
}
let pack_addr = pack.addr();
{
let existing_subs = load_subscriptions()?;
let is_new = !existing_subs.iter().any(|a| a == &pack_addr);
let cap = effective_max_equipped_packs();
if is_new && existing_subs.len() >= cap {
return Err(format!(
"You can equip at most {} packs. Remove one to add another.",
cap,
));
}
}
save_pack(&pack)?;
let mut subs = load_subscriptions()?;
if !subs.iter().any(|a| a == &pack_addr) {
subs.push(pack_addr.clone());
}
if !session.is_valid() {
return Err("Account swapped before subscription save — aborted".to_string());
}
save_subscriptions(&subs)?;
republish_emoji_list_debounced();
crate::traits::emit_event("emoji_packs_updated", &());
Ok(pack)
}
fn build_pack_event(pack: &EmojiPack) -> Result<EventBuilder, String> {
if pack.identifier.is_empty() {
return Err("pack identifier required".to_string());
}
let mut builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
.tag(Tag::custom(TagKind::custom("d"), [pack.identifier.clone()]));
if !pack.title.is_empty() {
builder = builder
.tag(Tag::custom(TagKind::custom("title"), [pack.title.clone()]))
.tag(Tag::custom(TagKind::custom("name"), [pack.title.clone()]));
}
if !pack.image_url.is_empty() {
builder = builder
.tag(Tag::custom(TagKind::custom("image"), [pack.image_url.clone()]))
.tag(Tag::custom(TagKind::custom("picture"), [pack.image_url.clone()]));
}
if !pack.description.is_empty() {
builder = builder
.tag(Tag::custom(TagKind::custom("description"), [pack.description.clone()]))
.tag(Tag::custom(TagKind::custom("about"), [pack.description.clone()]));
}
for e in &pack.emojis {
if e.shortcode.is_empty() || e.url.is_empty() { continue; }
builder = builder.tag(Tag::custom(
TagKind::custom("emoji"),
[e.shortcode.clone(), e.url.clone()],
));
}
Ok(builder)
}
pub async fn publish_pack(pack: &EmojiPack) -> Result<EmojiPack, String> {
let session = crate::state::SessionGuard::capture();
let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
let emoji_cap = effective_max_emojis_per_pack();
if pack.emojis.len() > emoji_cap {
return Err(format!(
"A pack can hold at most {} emojis.",
emoji_cap,
));
}
let mut to_save = pack.clone();
to_save.pubkey = my_pk.to_hex();
to_save.is_own = true;
let raw_addr = build_pack_addr(&to_save.pubkey, &to_save.identifier);
to_save.id = naddr_from_addr(&raw_addr)?;
to_save.updated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
{
let existing_subs = load_subscriptions()?;
let is_new = !existing_subs.iter().any(|a| a == &raw_addr);
let cap = effective_max_equipped_packs();
if is_new && existing_subs.len() >= cap {
return Err(format!(
"You can equip at most {} packs. Remove one to add another.",
cap,
));
}
}
let builder = build_pack_event(&to_save)?;
client.send_event_builder(builder).await
.map_err(|e| format!("publish kind 30030: {}", e))?;
if !session.is_valid() {
return Err("Account swapped during publish — local state untouched".to_string());
}
save_pack(&to_save)?;
let mut subs = load_subscriptions()?;
if !subs.iter().any(|a| a == &raw_addr) {
subs.push(raw_addr.clone());
if !session.is_valid() {
return Err("Account swapped before subscription save — aborted".to_string());
}
save_subscriptions(&subs)?;
republish_emoji_list_debounced();
}
crate::traits::emit_event("emoji_packs_updated", &());
crate::log_info!("[EmojiPacks] Published own pack `{}` with {} emoji(s)",
to_save.identifier, to_save.emojis.len());
Ok(to_save)
}
pub async fn delete_own_pack(id: &str) -> Result<(), String> {
let session = crate::state::SessionGuard::capture();
let parsed = parse_naddr(id)?;
let raw_addr = parsed.to_addr_string();
let my_pk = crate::state::my_public_key().ok_or_else(|| "Not logged in".to_string())?;
if parsed.pubkey != my_pk {
return Err("Cannot delete a pack you don't own".to_string());
}
let client = nostr_client().ok_or_else(|| "Nostr client not initialised".to_string())?;
let builder = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
.tag(Tag::custom(TagKind::custom("d"), [parsed.identifier.clone()]));
client.send_event_builder(builder).await
.map_err(|e| format!("publish empty kind 30030: {}", e))?;
if !session.is_valid() {
return Err("Account swapped during delete — local state untouched".to_string());
}
{
let mut conn = crate::db::get_write_connection_guard_static()?;
let tx = conn.transaction()
.map_err(|e| format!("begin delete tx: {}", e))?;
tx.execute("DELETE FROM emoji_pack_subscriptions WHERE addr = ?1",
rusqlite::params![raw_addr])
.map_err(|e| format!("drop subscription: {}", e))?;
tx.execute("DELETE FROM emoji_packs WHERE addr = ?1",
rusqlite::params![raw_addr])
.map_err(|e| format!("drop pack row: {}", e))?;
tx.commit()
.map_err(|e| format!("commit delete tx: {}", e))?;
}
republish_emoji_list_debounced();
crate::traits::emit_event("emoji_packs_updated", &());
crate::log_info!("[EmojiPacks] Deleted own pack `{}`", parsed.identifier);
Ok(())
}
pub async fn unsubscribe_pack(id: &str) -> Result<(), String> {
let session = crate::state::SessionGuard::capture();
let raw_addr = parse_naddr(id)?.to_addr_string();
let mut subs = load_subscriptions()?;
let before = subs.len();
subs.retain(|a| a != &raw_addr);
if subs.len() == before {
return Ok(()); }
if !session.is_valid() {
return Err("Account swapped before unsubscribe save — aborted".to_string());
}
save_subscriptions(&subs)?;
republish_emoji_list_debounced();
crate::traits::emit_event("emoji_packs_updated", &());
Ok(())
}
pub fn reorder_emoji_packs(ordered_ids: Vec<String>) -> Result<(), String> {
let session = crate::state::SessionGuard::capture();
let mut real_addrs: Vec<String> = Vec::with_capacity(ordered_ids.len());
let mut anchor = String::new();
let mut marker_present = false;
for id in &ordered_ids {
if id == THEME_SLOT_TOKEN {
marker_present = true;
anchor = real_addrs.last().cloned().unwrap_or_default();
} else {
real_addrs.push(parse_naddr(id)?.to_addr_string());
}
}
if !session.is_valid() {
return Err("Account swapped during reorder — aborted".to_string());
}
save_subscriptions(&real_addrs)?;
if marker_present {
save_theme_slot_anchor(&anchor)?;
}
republish_emoji_list_debounced();
crate::traits::emit_event("emoji_packs_updated", &());
Ok(())
}
pub fn get_theme_slot_anchor() -> Result<String, String> {
let raw = load_theme_slot_anchor();
if raw.is_empty() {
return Ok(String::new());
}
Ok(naddr_from_addr(&raw).unwrap_or_default())
}
#[cfg(test)]
mod tests {
use super::*;
fn keys() -> Keys {
Keys::generate()
}
fn build_pack_event(
k: &Keys,
d: &str,
title_tag: Option<(&str, &str)>,
image_tag: Option<(&str, &str)>,
desc_tag: Option<(&str, &str)>,
emojis: &[(&str, &str)],
) -> Event {
let mut tags: Vec<Tag> = Vec::new();
tags.push(Tag::custom(TagKind::custom("d"), [d]));
if let Some((key, val)) = title_tag {
tags.push(Tag::custom(TagKind::custom(key), [val]));
}
if let Some((key, val)) = image_tag {
tags.push(Tag::custom(TagKind::custom(key), [val]));
}
if let Some((key, val)) = desc_tag {
tags.push(Tag::custom(TagKind::custom(key), [val]));
}
for (code, url) in emojis {
tags.push(Tag::custom(TagKind::custom("emoji"), [*code, *url]));
}
EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
.tags(tags)
.sign_with_keys(k)
.unwrap()
}
fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) {
let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
crate::db::close_database();
crate::db::clear_id_caches();
let tmp = tempfile::tempdir().unwrap();
let account = keys().public_key().to_bech32().unwrap();
std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
crate::db::set_app_data_dir(tmp.path().to_path_buf());
crate::db::set_current_account(account.clone()).unwrap();
crate::db::init_database(&account).unwrap();
(tmp, guard)
}
fn seed_pack(k: &Keys, d: &str, emojis: &[(&str, &str)]) -> String {
let ev = build_pack_event(k, d, None, None, None, emojis);
let pack = parse_pack_from_event(&ev, None).unwrap();
save_pack(&pack).unwrap();
pack.addr()
}
fn seed_usage(url: &str) {
let conn = crate::db::get_write_connection_guard_static().unwrap();
conn.execute(
"INSERT INTO emoji_usage (kind, id, url, score, last_used) VALUES (1, ?1, ?1, 1.0, 0)",
rusqlite::params![url],
).unwrap();
}
fn usage_count() -> i64 {
let conn = crate::db::get_db_connection_guard_static().unwrap();
conn.query_row("SELECT COUNT(*) FROM emoji_usage", [], |r| r.get(0)).unwrap()
}
fn pack_status(addr: &str) -> u8 {
load_cached_pack(addr).unwrap().unwrap().status
}
fn build_deletion_event(k: &Keys, addr: &str) -> Event {
EventBuilder::new(Kind::EventDeletion, "")
.tag(Tag::custom(TagKind::custom("a"), [addr]))
.sign_with_keys(k)
.unwrap()
}
#[test]
fn classify_verdict_table() {
let k = keys();
let live = build_pack_event(&k, "p", None, None, None, &[("a", "https://e.x/a.png")]);
let empty = build_pack_event(&k, "p", None, None, None, &[]);
let addr = format!("30030:{}:p", k.public_key().to_hex());
let del = build_deletion_event(&k, &addr);
let (o, p) = classify_pack(Some(&live), None, None, false);
assert_eq!(o, PackFetchOutcome::Found);
assert!(p.is_some());
let (o, _) = classify_pack(Some(&empty), None, None, true);
assert_eq!(o, PackFetchOutcome::Tombstoned);
let (o, _) = classify_pack(None, Some(&del), None, true);
assert_eq!(o, PackFetchOutcome::Tombstoned);
let ts = Timestamp::from_secs(1_700_000_000);
let live_pinned = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
.tags(vec![
Tag::custom(TagKind::custom("d"), ["p"]),
Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
])
.custom_created_at(ts)
.sign_with_keys(&k)
.unwrap();
let del_at = |secs: u64| {
EventBuilder::new(Kind::EventDeletion, "")
.tag(Tag::custom(TagKind::custom("a"), [addr.as_str()]))
.custom_created_at(Timestamp::from_secs(secs))
.sign_with_keys(&k)
.unwrap()
};
let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_000)), None, false);
assert_eq!(o, PackFetchOutcome::Found);
let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_700_000_001)), None, false);
assert_eq!(o, PackFetchOutcome::Tombstoned);
let (o, _) = classify_pack(Some(&live_pinned), Some(&del_at(1_699_999_999)), None, false);
assert_eq!(o, PackFetchOutcome::Found);
let (o, _) = classify_pack(None, None, None, true);
assert_eq!(o, PackFetchOutcome::CleanMiss);
let (o, _) = classify_pack(None, None, None, false);
assert_eq!(o, PackFetchOutcome::Unreachable);
}
#[test]
fn classify_invalid_emojis_is_not_a_tombstone() {
let k = keys();
let ev = build_pack_event(&k, "weird", None, None, None, &[("bad!code", "https://e.x/a.png")]);
assert!(parse_pack_from_event(&ev, None).is_none(), "precondition: unparseable");
let (o, _) = classify_pack(Some(&ev), None, None, true);
assert_eq!(o, PackFetchOutcome::Unreachable);
}
#[test]
fn absence_gate_table() {
let some_writes = vec![RelayUrl::parse("wss://relay.author.example").unwrap()];
let no_writes: Vec<RelayUrl> = Vec::new();
assert!(!absence_is_clean(false, 5, Some(&no_writes), true, 1));
assert!(!absence_is_clean(true, 1, Some(&no_writes), true, 1));
assert!(!absence_is_clean(true, 3, None, true, 1));
assert!(absence_is_clean(true, 2, Some(&no_writes), false, 0));
assert!(!absence_is_clean(true, 3, Some(&some_writes), false, 0));
assert!(!absence_is_clean(true, 3, Some(&some_writes), true, 0));
assert!(absence_is_clean(true, 3, Some(&some_writes), true, 1));
}
#[test]
fn deletion_matching_is_author_bound() {
let author = keys();
let stranger = keys();
let addr = format!("30030:{}:p", author.public_key().to_hex());
let forged = build_deletion_event(&stranger, &addr);
assert!(!deletion_matches(&forged, &author.public_key(), &addr));
let real = build_deletion_event(&author, &addr);
assert!(deletion_matches(&real, &author.public_key(), &addr));
}
#[test]
fn tombstone_revokes_immediately_and_purges_frecency() {
let (_tmp, _guard) = init_test_db();
let k = keys();
let addr = seed_pack(&k, "deadpack", &[("boom", "https://e.x/boom.png")]);
seed_usage("https://e.x/boom.png");
seed_usage("https://e.x/unrelated.png");
let changed = apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
assert!(changed, "tombstone must flip status in one sweep");
assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
assert_eq!(usage_count(), 1, "only the dead pack's usage rows purge");
}
#[test]
fn found_self_heals_from_revoked() {
let (_tmp, _guard) = init_test_db();
let k = keys();
let addr = seed_pack(&k, "phoenix", &[("rise", "https://e.x/r.png")]);
apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED);
let changed = apply_pack_health(&addr, &PackFetchOutcome::Found, 2_000).unwrap();
assert!(changed);
assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "a republished pack revives");
}
#[test]
fn clean_miss_gauntlet_rate_limit_and_promotion() {
let (_tmp, _guard) = init_test_db();
let k = keys();
let addr = seed_pack(&k, "fading", &[("bye", "https://e.x/bye.png")]);
let t0: i64 = 1_000_000;
let h = 3_600;
assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0).unwrap());
assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + h).unwrap());
assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 13 * h).unwrap());
assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 26 * h).unwrap());
assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE, "count alone must not promote");
let changed = apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t0 + 49 * h).unwrap();
assert!(changed);
assert_eq!(pack_status(&addr), PACK_STATUS_MISSING);
}
#[test]
fn unreachable_never_moves_counters() {
let (_tmp, _guard) = init_test_db();
let k = keys();
let addr = seed_pack(&k, "offline", &[("zz", "https://e.x/z.png")]);
for t in [1_000i64, 200_000, 400_000, 800_000] {
assert!(!apply_pack_health(&addr, &PackFetchOutcome::Unreachable, t).unwrap());
}
assert_eq!(pack_status(&addr), PACK_STATUS_ACTIVE);
let conn = crate::db::get_db_connection_guard_static().unwrap();
let miss: i64 = conn.query_row(
"SELECT miss_count FROM emoji_packs WHERE addr = ?1",
rusqlite::params![addr], |r| r.get(0),
).unwrap();
assert_eq!(miss, 0, "offline sweeps must not accrue misses");
}
#[test]
fn revoked_ignores_clean_misses() {
let (_tmp, _guard) = init_test_db();
let k = keys();
let addr = seed_pack(&k, "sealed", &[("x", "https://e.x/x.png")]);
apply_pack_health(&addr, &PackFetchOutcome::Tombstoned, 1_000).unwrap();
for t in [100_000i64, 300_000, 600_000] {
assert!(!apply_pack_health(&addr, &PackFetchOutcome::CleanMiss, t).unwrap());
}
assert_eq!(pack_status(&addr), PACK_STATUS_REVOKED, "misses can't downgrade a tombstone verdict");
}
#[test]
fn empty_pack_event_is_the_tombstone_shape() {
let k = keys();
let ev = build_pack_event(&k, "gone", None, None, None, &[]);
assert!(parse_pack_from_event(&ev, None).is_none());
}
#[test]
fn parse_pack_reads_nip51_spec_tags() {
let k = keys();
let ev = build_pack_event(
&k, "myPack",
Some(("title", "Spec Pack")),
Some(("image", "https://example.com/p.png")),
Some(("description", "specd")),
&[("smile", "https://e.x/s.png"), ("heart", "https://e.x/h.png")],
);
let pack = parse_pack_from_event(&ev, None).unwrap();
assert_eq!(pack.identifier, "myPack");
assert_eq!(pack.title, "Spec Pack");
assert_eq!(pack.image_url, "https://example.com/p.png");
assert_eq!(pack.description, "specd");
assert_eq!(pack.emojis.len(), 2);
assert_eq!(pack.addr(), format!("30030:{}:myPack", k.public_key().to_hex()));
}
#[test]
fn parse_pack_falls_back_to_ditto_tags_when_spec_missing() {
let k = keys();
let ev = build_pack_event(
&k, "ditto",
Some(("name", "Ditto Pack")),
Some(("picture", "https://example.com/d.png")),
Some(("about", "ditto-style")),
&[("yes", "https://e.x/y.png")],
);
let pack = parse_pack_from_event(&ev, None).unwrap();
assert_eq!(pack.title, "Ditto Pack");
assert_eq!(pack.image_url, "https://example.com/d.png");
assert_eq!(pack.description, "ditto-style");
}
#[test]
fn parse_pack_prefers_spec_tags_over_ditto() {
let k = keys();
let mut tags: Vec<Tag> = vec![
Tag::custom(TagKind::custom("d"), ["both"]),
Tag::custom(TagKind::custom("title"), ["SpecTitle"]),
Tag::custom(TagKind::custom("name"), ["DittoName"]),
Tag::custom(TagKind::custom("image"), ["spec.png"]),
Tag::custom(TagKind::custom("picture"), ["ditto.png"]),
Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
];
tags.extend(std::iter::empty());
let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
.tags(tags).sign_with_keys(&k).unwrap();
let pack = parse_pack_from_event(&ev, None).unwrap();
assert_eq!(pack.title, "SpecTitle");
assert_eq!(pack.image_url, "spec.png");
}
#[test]
fn parse_pack_returns_none_without_d_tag() {
let k = keys();
let ev = EventBuilder::new(Kind::Custom(KIND_EMOJI_SET), "")
.tags(vec![
Tag::custom(TagKind::custom("title"), ["No D"]),
Tag::custom(TagKind::custom("emoji"), ["a", "https://e.x/a.png"]),
])
.sign_with_keys(&k).unwrap();
assert!(parse_pack_from_event(&ev, None).is_none());
}
#[test]
fn parse_pack_returns_none_when_no_valid_emojis() {
let k = keys();
let ev = build_pack_event(&k, "empty", Some(("title", "Empty")), None, None, &[]);
assert!(parse_pack_from_event(&ev, None).is_none());
}
#[test]
fn parse_pack_rejects_invalid_shortcodes() {
let k = keys();
let ev = build_pack_event(
&k, "mix", Some(("title", "Mix")), None, None,
&[("ok_name", "https://e.x/a.png"),
("bad name", "https://e.x/b.png"),
("colons:no", "https://e.x/c.png"),
("", "https://e.x/d.png"),
("dash-ok", "https://e.x/e.png")],
);
let pack = parse_pack_from_event(&ev, None).unwrap();
let codes: Vec<&str> = pack.emojis.iter().map(|e| e.shortcode.as_str()).collect();
assert_eq!(codes, vec!["ok_name", "dash-ok"]);
}
#[test]
fn parse_pack_dedupes_shortcodes_first_wins() {
let k = keys();
let ev = build_pack_event(
&k, "dup", Some(("title", "Dup")), None, None,
&[("smile", "https://e.x/first.png"),
("smile", "https://e.x/second.png")],
);
let pack = parse_pack_from_event(&ev, None).unwrap();
assert_eq!(pack.emojis.len(), 1);
assert_eq!(pack.emojis[0].url, "https://e.x/first.png");
}
#[test]
fn parse_pack_marks_is_own_only_when_my_pubkey_matches() {
let k = keys();
let ev = build_pack_event(&k, "mine", Some(("title", "Mine")), None, None,
&[("a", "https://e.x/a.png")]);
let my_hex = k.public_key().to_hex();
let pack_mine = parse_pack_from_event(&ev, Some(&my_hex)).unwrap();
assert!(pack_mine.is_own);
let stranger = keys().public_key().to_hex();
let pack_other = parse_pack_from_event(&ev, Some(&stranger)).unwrap();
assert!(!pack_other.is_own);
}
#[test]
fn pack_address_to_string_round_trips() {
let k = keys();
let hex = k.public_key().to_hex();
let addr = PackAddress {
kind: 30030,
pubkey: k.public_key(),
identifier: "myPack".to_string(),
};
assert_eq!(addr.to_addr_string(), format!("30030:{}:myPack", hex));
let parsed = parse_pack_address(&addr.to_addr_string()).unwrap();
assert_eq!(parsed, addr);
}
#[test]
fn parse_naddr_round_trips_kind_30030_coordinate() {
let k = keys();
let coord = nostr_sdk::nips::nip01::Coordinate {
kind: Kind::Custom(30030),
public_key: k.public_key(),
identifier: "trip".to_string(),
};
let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
coordinate: coord,
relays: Vec::new(),
};
let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
let parsed = parse_naddr(&naddr).unwrap();
assert_eq!(parsed.kind, 30030);
assert_eq!(parsed.pubkey, k.public_key());
assert_eq!(parsed.identifier, "trip");
}
#[test]
fn parse_naddr_rejects_non_30030_kinds() {
let k = keys();
let coord = nostr_sdk::nips::nip01::Coordinate {
kind: Kind::Custom(30023), public_key: k.public_key(),
identifier: "essay".to_string(),
};
let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
coordinate: coord,
relays: Vec::new(),
};
let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
let err = parse_naddr(&naddr).unwrap_err();
assert!(err.contains("expected kind 30030"),
"expected kind-rejection error, got: {}", err);
}
#[test]
fn parse_naddr_strips_nostr_uri_prefix() {
let k = keys();
let coord = nostr_sdk::nips::nip01::Coordinate {
kind: Kind::Custom(30030),
public_key: k.public_key(),
identifier: "prefixed".to_string(),
};
let n19 = nostr_sdk::nips::nip19::Nip19Coordinate {
coordinate: coord,
relays: Vec::new(),
};
let naddr = nostr_sdk::nips::nip19::Nip19::Coordinate(n19).to_bech32().unwrap();
let with_prefix = format!("nostr:{}", naddr);
let parsed = parse_naddr(&with_prefix).unwrap();
assert_eq!(parsed.identifier, "prefixed");
}
#[test]
fn parse_naddr_rejects_garbage_input() {
assert!(parse_naddr("not an naddr").is_err());
assert!(parse_naddr("naddr1invalid").is_err());
assert!(parse_naddr("").is_err());
}
#[test]
fn parse_pack_address_round_trips_valid_input() {
let k = keys();
let hex = k.public_key().to_hex();
let addr = format!("30030:{}:myId", hex);
let parsed = parse_pack_address(&addr).unwrap();
assert_eq!(parsed.kind, 30030);
assert_eq!(parsed.pubkey, k.public_key());
assert_eq!(parsed.identifier, "myId");
}
#[test]
fn parse_pack_address_rejects_wrong_kind() {
let hex = keys().public_key().to_hex();
let addr = format!("10030:{}:x", hex);
assert!(parse_pack_address(&addr).is_err());
}
#[test]
fn parse_pack_address_rejects_malformed_pubkey() {
let addr = "30030:not-hex:x".to_string();
assert!(parse_pack_address(&addr).is_err());
}
#[test]
fn parse_pack_address_preserves_colons_in_identifier() {
let hex = keys().public_key().to_hex();
let addr = format!("30030:{}:id:with:colons", hex);
let parsed = parse_pack_address(&addr).unwrap();
assert_eq!(parsed.identifier, "id:with:colons");
}
#[test]
fn parse_inner_tag_list_extracts_valid_a_tags() {
let hex_a = keys().public_key().to_hex();
let hex_b = keys().public_key().to_hex();
let plaintext = format!(
r#"[["a","30030:{a}:packA"],["a","30030:{b}:packB"],["a","malformed"],["a","10030:{a}:wrongkind"],["p","not-an-a-tag"]]"#,
a = hex_a,
b = hex_b,
);
let addrs = parse_inner_tag_list(&plaintext);
assert_eq!(addrs.len(), 2);
assert_eq!(addrs[0].identifier, "packA");
assert_eq!(addrs[1].identifier, "packB");
}
#[test]
fn parse_inner_tag_list_returns_empty_on_malformed_json() {
assert!(parse_inner_tag_list("not json").is_empty());
assert!(parse_inner_tag_list("").is_empty());
}
#[test]
fn theme_slot_marker_round_trips_through_publish_and_parse() {
let a = keys().public_key().to_hex();
let b = keys().public_key().to_hex();
let c = keys().public_key().to_hex();
let addr_a = format!("30030:{}:packA", a);
let addr_b = format!("30030:{}:packB", b);
let addr_c = format!("30030:{}:packC", c);
let addrs = vec![addr_a.clone(), addr_b.clone(), addr_c.clone()];
let inner = build_inner_tags_with_marker(&addrs, &addr_b);
let marker_count = inner.iter()
.filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
.count();
assert_eq!(marker_count, 1, "exactly one marker is emitted");
let plaintext = serde_json::to_string(&inner).unwrap();
let (parsed, anchor) = parse_inner_tag_list_with_anchor(&plaintext);
assert_eq!(parsed, addrs, "addr order survives the round trip");
assert_eq!(anchor.as_deref(), Some(addr_b.as_str()), "anchor is the 2nd pack");
let old = format!(r#"[["a","{}"],["a","{}"]]"#, addr_a, addr_b);
let (old_addrs, old_anchor) = parse_inner_tag_list_with_anchor(&old);
assert_eq!(old_addrs, vec![addr_a.clone(), addr_b.clone()]);
assert_eq!(old_anchor, None, "a list without the marker has no anchor");
let top = build_inner_tags_with_marker(&addrs, "");
assert_eq!(top[0], vec![THEME_SLOT_TOKEN.to_string()]);
let (_, top_anchor) =
parse_inner_tag_list_with_anchor(&serde_json::to_string(&top).unwrap());
assert_eq!(top_anchor.as_deref(), Some(""), "top slot = empty anchor");
let gone = format!("30030:{}:ghost", keys().public_key().to_hex());
let fallback = build_inner_tags_with_marker(&addrs, &gone);
assert_eq!(fallback[0], vec![THEME_SLOT_TOKEN.to_string()]);
let fb_markers = fallback.iter()
.filter(|t| t.first().map(String::as_str) == Some(THEME_SLOT_TOKEN))
.count();
assert_eq!(fb_markers, 1, "still exactly one marker on the fallback path");
}
#[test]
fn shortcode_validator_accepts_alphanum_dash_underscore() {
assert!(is_valid_shortcode("smile"));
assert!(is_valid_shortcode("smile_face"));
assert!(is_valid_shortcode("smile-face"));
assert!(is_valid_shortcode("Smile2"));
assert!(!is_valid_shortcode(""));
assert!(!is_valid_shortcode("smile face"));
assert!(!is_valid_shortcode("smile:face"));
assert!(!is_valid_shortcode("😀"));
assert!(!is_valid_shortcode("love~2"));
}
#[test]
fn resolve_token_disambiguates_duplicate_shortcodes() {
let mut by_code: HashMap<String, Vec<String>> = HashMap::new();
by_code.insert(
"love".to_string(),
vec!["https://a.example/love.png".to_string(), "https://b.example/love.gif".to_string()],
);
by_code.insert("cat".to_string(), vec!["https://a.example/cat.png".to_string()]);
assert_eq!(resolve_emoji_token(&by_code, "love").as_deref(), Some("https://a.example/love.png"));
assert_eq!(resolve_emoji_token(&by_code, "love~1").as_deref(), Some("https://a.example/love.png"));
assert_eq!(resolve_emoji_token(&by_code, "love~2").as_deref(), Some("https://b.example/love.gif"));
assert_eq!(resolve_emoji_token(&by_code, "love~3"), None);
assert_eq!(resolve_emoji_token(&by_code, "love~0"), None);
assert_eq!(resolve_emoji_token(&by_code, "nope~1"), None);
assert_eq!(resolve_emoji_token(&by_code, "cat").as_deref(), Some("https://a.example/cat.png"));
assert_eq!(resolve_emoji_token(&by_code, "cat~2"), None);
}
#[test]
fn message_emoji_tags_accept_disambiguation_separator() {
let tags = vec![
vec!["emoji".to_string(), "love~2".to_string(), "https://b.example/love.gif".to_string()],
vec!["emoji".to_string(), "bad name".to_string(), "https://x".to_string()],
];
let out = crate::types::EmojiTag::extract_from_stored(&tags);
assert_eq!(out.len(), 1);
assert_eq!(out[0].shortcode, "love~2");
}
}