From 6dad27469d0d001e71f0479af9adba49dabea808 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 11 Oct 2026 16:43:51 +0700 Subject: [PATCH] Support NIP-30 custom emoji in messages and reactions --- crates/chat/src/message.rs | 98 ++++++++++++++++++++++++++++++++++ crates/chat_ui/src/lib.rs | 32 +++++++---- crates/chat_ui/src/text.rs | 62 ++++++++++++++++++++- crates/nip29/src/group.rs | 8 ++- crates/nip29_ui/src/lib.rs | 10 +++- crates/nip29_ui/src/message.rs | 10 ++-- crates/ui/src/markdown.rs | 35 +++++++++--- crates/ui/src/message.rs | 25 ++++++--- 8 files changed, 250 insertions(+), 30 deletions(-) diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index 72ebb3e9..51bea6fc 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -22,6 +22,8 @@ pub struct Message { pub created_at: Timestamp, /// List of mentioned public keys in the message pub mentions: Vec, + /// 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, /// 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 { + 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 { 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); + } +} diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 54d48839..d5df444f 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -10,9 +10,10 @@ use gpui::prelude::FluentBuilder; use gpui::{ AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, - ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, - Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, - WeakEntity, Window, div, img, list, px, red, relative, retain_all, svg, white, + ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri, + StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification, + SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative, + retain_all, svg, white, }; use itertools::Itertools; use nostr_sdk::prelude::*; @@ -78,7 +79,7 @@ pub struct ChatPanel { message_index: HashMap, /// All reactions - reactions: BTreeMap>, + reactions: BTreeMap, PublicKey)>>, /// Mapping message ids to their rendered texts rendered_texts_by_id: BTreeMap, @@ -535,11 +536,16 @@ impl ChatPanel { return; } + let emojis = Message::emojis_from_tags(&event.tags); + let image = Message::reaction_image(&event.content, &emojis) + .map(|url| SharedUri::from(url.to_string())); + for id in event.tags.event_ids() { - self.reactions - .entry(id) - .or_default() - .push((SharedString::from(&event.content), event.pubkey)); + self.reactions.entry(id).or_default().push(( + SharedString::from(&event.content), + image.clone(), + event.pubkey, + )); } cx.notify(); @@ -563,7 +569,7 @@ impl ChatPanel { } /// Get a reaction by its target ID (returns reference, no allocation) - fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] { + fn reaction(&self, id: &EventId) -> &[(SharedString, Option, PublicKey)] { self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[]) } @@ -1081,7 +1087,13 @@ impl ChatPanel { .rendered_texts_by_id .entry(message.id) .or_insert_with(|| { - text::rendered_text(&message.content, &message.mentions, &persons, cx) + text::rendered_text( + &message.content, + &message.mentions, + &message.emojis, + &persons, + cx, + ) }) .element(ix.into(), window, cx); diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index 318115bd..771de157 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -1,17 +1,18 @@ use chat::Mention; use gpui::{App, Entity}; +use nostr_sdk::prelude::Url; use person::PersonRegistry; use ui::markdown::InlineReplacement; pub use ui::markdown::RenderedText; -/// Render message `content`, replacing mentions with their display names. pub fn rendered_text( content: &str, mentions: &[Mention], + emojis: &[(String, Url)], persons: &Entity, cx: &App, ) -> RenderedText { - let replacements = mentions + let mut replacements = mentions .iter() .map(|mention| { InlineReplacement::new( @@ -21,5 +22,62 @@ pub fn rendered_text( }) .collect::>(); + replacements.extend(emoji_replacements(content, emojis)); + replacements.sort_by_key(|replacement| replacement.range.start); + RenderedText::new(content, &replacements) } + +fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec { + let mut replacements = Vec::new(); + + for (shortcode, url) in emojis { + let pattern = format!(":{shortcode}:"); + for (start, _) in content.match_indices(&pattern) { + let range = start..start + pattern.len(); + + // Skip matches glued to word characters, like the `30` in `12:30:45` + if content[..start].ends_with(char::is_alphanumeric) + || content[range.end..].starts_with(char::is_alphanumeric) + { + continue; + } + + replacements.push(InlineReplacement::image( + range, + shortcode.clone(), + url.to_string(), + )); + } + } + + replacements +} + +#[cfg(test)] +mod tests { + use super::*; + + fn emoji(shortcode: &str, url: &str) -> (String, Url) { + (String::from(shortcode), Url::parse(url).expect("valid url")) + } + + #[test] + fn declared_shortcodes_become_image_replacements() { + let emojis = vec![emoji("soapbox", "https://example.com/soapbox.png")]; + + let replacements = emoji_replacements("hi :soapbox: and :other:", &emojis); + + assert_eq!(replacements.len(), 1); + assert_eq!(replacements[0].range, 3..12); + } + + #[test] + fn matches_glued_to_word_characters_are_skipped() { + let emojis = vec![emoji("30", "https://example.com/30.png")]; + + assert!(emoji_replacements("at 12:30:45 exactly", &emojis).is_empty()); + assert!(emoji_replacements("x:30: and :30:y", &emojis).is_empty()); + assert_eq!(emoji_replacements("(:30:)", &emojis).len(), 1); + } +} diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index e880293f..3d53ec8e 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -3,7 +3,7 @@ use std::time::Duration; use anyhow::{Result, anyhow, bail}; use chat::Message; -use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; +use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task}; use nostr_sdk::prelude::*; use smallvec::{SmallVec, smallvec}; use state::NostrRegistry; @@ -31,6 +31,7 @@ enum Absorbed { pub struct Reaction { pub id: EventId, pub emoji: String, + pub image: Option, pub author: PublicKey, } @@ -756,9 +757,14 @@ impl Group { return false; } + let emojis = Message::emojis_from_tags(&event.tags); + let image = Message::reaction_image(&event.content, &emojis) + .map(|url| SharedUri::from(url.to_string())); + self.reactions.entry(target).or_default().push(Reaction { id: event.id, emoji: event.content.clone(), + image, author: event.pubkey, }); diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index a0c56e1b..4fa26544 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -886,7 +886,15 @@ impl GroupPanel { let content = self .rendered_texts_by_id .entry(message.id) - .or_insert_with(|| rendered_text(&message.content, &message.mentions, &persons, cx)) + .or_insert_with(|| { + rendered_text( + &message.content, + &message.mentions, + &message.emojis, + &persons, + cx, + ) + }) .element(ix.into(), window, cx); let admin = nostr diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 865ad122..073cd7ae 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -43,9 +43,13 @@ pub(crate) fn render( let author = persons.read(cx).get(&message.author, cx); let reaction_groups = group_reactions( - reactions - .iter() - .map(|reaction| (SharedString::from(reaction.emoji.as_str()), reaction.author)), + reactions.iter().map(|reaction| { + ( + SharedString::from(reaction.emoji.as_str()), + reaction.image.clone(), + reaction.author, + ) + }), nostr.read(cx).current_user(), ); diff --git a/crates/ui/src/markdown.rs b/crates/ui/src/markdown.rs index d2a9fa02..e60f06de 100644 --- a/crates/ui/src/markdown.rs +++ b/crates/ui/src/markdown.rs @@ -1,8 +1,8 @@ use std::ops::Range; use gpui::{ - AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, StyleRefinement, - Styled as _, Window, + AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, SharedUri, + StyleRefinement, Styled as _, Window, }; use gpui_base::{TextView, TextViewStyle}; use theme::ActiveTheme as _; @@ -12,6 +12,8 @@ use theme::ActiveTheme as _; pub struct InlineReplacement { pub range: Range, pub text: SharedString, + /// When set, the span renders as this image (NIP-30 custom emoji) + pub image: Option, } impl InlineReplacement { @@ -19,6 +21,19 @@ impl InlineReplacement { Self { range, text: text.into(), + image: None, + } + } + + pub fn image( + range: Range, + alt: impl Into, + url: impl Into, + ) -> Self { + Self { + range, + text: alt.into(), + image: Some(url.into()), } } } @@ -46,11 +61,17 @@ impl RenderedText { } text.push_str(&content[last..range.start]); - text.push_str(&format!( - "[{}](<{}>)", - escape_link_text(&replacement.text), - &content[range.start..range.end] - )); + match &replacement.image { + Some(url) => text.push_str(&format!( + "![{}](<{url}>)", + escape_link_text(&replacement.text) + )), + None => text.push_str(&format!( + "[{}](<{}>)", + escape_link_text(&replacement.text), + &content[range.start..range.end] + )), + } last = range.end; } text.push_str(&content[last..]); diff --git a/crates/ui/src/message.rs b/crates/ui/src/message.rs index 99abb744..3b1c02ae 100644 --- a/crates/ui/src/message.rs +++ b/crates/ui/src/message.rs @@ -370,26 +370,36 @@ impl RenderOnce for MessageMedia { /// Reactions of one emoji with the count of authors and whether the current user reacted. pub struct ReactionGroup { pub emoji: SharedString, + /// Image of a custom emoji (NIP-30) reaction + pub image: Option, pub count: usize, pub mine: bool, } -/// Group (emoji, author) pairs by emoji, sorted by emoji, marking groups containing `me`. +/// Group (emoji, image, author) triples by emoji, sorted by emoji. pub fn group_reactions( - reactions: impl IntoIterator, + reactions: impl IntoIterator, A)>, me: Option, ) -> Vec { - let mut groups: BTreeMap = BTreeMap::new(); + let mut groups: BTreeMap)> = BTreeMap::new(); - for (emoji, author) in reactions { + for (emoji, image, author) in reactions { let group = groups.entry(emoji).or_default(); group.0 += 1; group.1 |= me.as_ref() == Some(&author); + if group.2.is_none() { + group.2 = image; + } } groups .into_iter() - .map(|(emoji, (count, mine))| ReactionGroup { emoji, count, mine }) + .map(|(emoji, (count, mine, image))| ReactionGroup { + emoji, + image, + count, + mine, + }) .collect() } @@ -438,7 +448,10 @@ impl RenderOnce for ReactionBar { .border_color(cx.theme().element_active) }) .when(!group.mine, |this| this.border_color(cx.theme().border)) - .child(group.emoji) + .when(group.image.is_none(), |this| this.child(group.emoji)) + .when_some(group.image, |this, image| { + this.child(img(image).size(px(16.)).flex_shrink_0()) + }) .child(SharedString::from(group.count.to_string())) })) }