Support NIP-30 custom emoji in messages and reactions

This commit is contained in:
2026-10-11 16:43:51 +07:00
parent 8a043b8d49
commit 6dad27469d
8 changed files with 250 additions and 30 deletions
+98
View File
@@ -22,6 +22,8 @@ pub struct Message {
pub created_at: Timestamp,
/// List of mentioned public keys in the message
pub mentions: Vec<Mention>,
/// Custom emoji (NIP-30) declared by this message's `emoji` tags
pub emojis: Vec<(String, Url)>,
/// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>,
/// Encrypted file attachment
@@ -85,6 +87,7 @@ fn from_parts(
let has_file = file.is_some();
let replies_to = extract_reply_ids(tags);
let emojis = Message::emojis_from_tags(tags);
// For file messages `.content` is the encrypted blob URL, not text or media
let mentions = if has_file {
@@ -106,6 +109,7 @@ fn from_parts(
media,
created_at,
mentions,
emojis,
replies_to,
file,
}
@@ -146,6 +150,35 @@ impl Message {
self.content.clone().into()
}
/// Custom emoji (NIP-30) declared by `emoji` tags, first per shortcode wins
pub fn emojis_from_tags(tags: &Tags) -> Vec<(String, Url)> {
let mut emojis: Vec<(String, Url)> = Vec::new();
for tag in tags.iter() {
let Ok(Nip30Tag::Emoji {
shortcode,
image_url,
..
}) = Nip30Tag::try_from(tag)
else {
continue;
};
if !emojis.iter().any(|(existing, _)| *existing == shortcode) {
emojis.push((shortcode, image_url));
}
}
emojis
}
/// Image URL of a `:shortcode:` reaction, if the shortcode is declared
pub fn reaction_image(content: &str, emojis: &[(String, Url)]) -> Option<Url> {
let shortcode = content.strip_prefix(':')?.strip_suffix(':')?;
let (_, url) = emojis.iter().find(|(name, _)| name == shortcode)?;
Some(url.clone())
}
}
/// New message.
@@ -226,3 +259,68 @@ fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
replies_to
}
#[cfg(test)]
mod tests {
use super::*;
fn rumor(kind: Kind, content: &str, tags: Tags) -> UnsignedEvent {
let sender = Keys::generate();
let mut rumor = EventBuilder::new(kind, content)
.tags(tags)
.finalize_unsigned(sender.public_key());
rumor.ensure_id();
rumor
}
fn emoji_tag(shortcode: &str, url: &str) -> Tag {
Tag::parse(vec!["emoji", shortcode, url]).expect("valid emoji tag")
}
#[test]
fn emojis_from_tags_parses_and_deduplicates() {
let tags = Tags::from_list(vec![
emoji_tag("soapbox", "https://example.com/soapbox.png"),
emoji_tag("soapbox", "https://example.com/other.png"),
emoji_tag("bad shortcode", "https://example.com/bad.png"),
]);
let emojis = Message::emojis_from_tags(&tags);
assert_eq!(emojis.len(), 1);
assert_eq!(emojis[0].0, "soapbox");
assert_eq!(
emojis[0].1,
Url::parse("https://example.com/soapbox.png").unwrap()
);
}
#[test]
fn message_parses_emoji_tags() {
let tags = Tags::from_list(vec![emoji_tag(
"soapbox",
"https://example.com/soapbox.png",
)]);
let message = Message::from(&rumor(Kind::PrivateDirectMessage, "hi :soapbox:", tags));
assert_eq!(message.emojis.len(), 1);
assert_eq!(message.emojis[0].0, "soapbox");
}
#[test]
fn reaction_image_matches_declared_shortcode() {
let emojis = vec![(
String::from("soapbox"),
Url::parse("https://example.com/soapbox.png").unwrap(),
)];
assert_eq!(
Message::reaction_image(":soapbox:", &emojis),
Some(Url::parse("https://example.com/soapbox.png").unwrap())
);
assert_eq!(Message::reaction_image("😂", &emojis), None);
assert_eq!(Message::reaction_image(":unknown:", &emojis), None);
}
}