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, pub created_at: Timestamp,
/// List of mentioned public keys in the message /// List of mentioned public keys in the message
pub mentions: Vec<Mention>, 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 /// List of event of the message this message is a reply to
pub replies_to: Vec<EventId>, pub replies_to: Vec<EventId>,
/// Encrypted file attachment /// Encrypted file attachment
@@ -85,6 +87,7 @@ fn from_parts(
let has_file = file.is_some(); let has_file = file.is_some();
let replies_to = extract_reply_ids(tags); 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 // For file messages `.content` is the encrypted blob URL, not text or media
let mentions = if has_file { let mentions = if has_file {
@@ -106,6 +109,7 @@ fn from_parts(
media, media,
created_at, created_at,
mentions, mentions,
emojis,
replies_to, replies_to,
file, file,
} }
@@ -146,6 +150,35 @@ impl Message {
self.content.clone().into() 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. /// New message.
@@ -226,3 +259,68 @@ fn extract_reply_ids(inner: &Tags) -> Vec<EventId> {
replies_to 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);
}
}
+22 -10
View File
@@ -10,9 +10,10 @@ use gpui::prelude::FluentBuilder;
use gpui::{ use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle, AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, StatefulInteractiveElement, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
Styled, StyledImage, Subscription, SystemNotification, SystemNotificationAction, Task, StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
WeakEntity, Window, div, img, list, px, red, relative, retain_all, svg, white, SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
retain_all, svg, white,
}; };
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
@@ -78,7 +79,7 @@ pub struct ChatPanel {
message_index: HashMap<EventId, usize>, message_index: HashMap<EventId, usize>,
/// All reactions /// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>, reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
/// Mapping message ids to their rendered texts /// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>, rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
@@ -535,11 +536,16 @@ impl ChatPanel {
return; 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() { for id in event.tags.event_ids() {
self.reactions self.reactions.entry(id).or_default().push((
.entry(id) SharedString::from(&event.content),
.or_default() image.clone(),
.push((SharedString::from(&event.content), event.pubkey)); event.pubkey,
));
} }
cx.notify(); cx.notify();
@@ -563,7 +569,7 @@ impl ChatPanel {
} }
/// Get a reaction by its target ID (returns reference, no allocation) /// 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<SharedUri>, PublicKey)] {
self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[]) self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[])
} }
@@ -1081,7 +1087,13 @@ impl ChatPanel {
.rendered_texts_by_id .rendered_texts_by_id
.entry(message.id) .entry(message.id)
.or_insert_with(|| { .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); .element(ix.into(), window, cx);
+60 -2
View File
@@ -1,17 +1,18 @@
use chat::Mention; use chat::Mention;
use gpui::{App, Entity}; use gpui::{App, Entity};
use nostr_sdk::prelude::Url;
use person::PersonRegistry; use person::PersonRegistry;
use ui::markdown::InlineReplacement; use ui::markdown::InlineReplacement;
pub use ui::markdown::RenderedText; pub use ui::markdown::RenderedText;
/// Render message `content`, replacing mentions with their display names.
pub fn rendered_text( pub fn rendered_text(
content: &str, content: &str,
mentions: &[Mention], mentions: &[Mention],
emojis: &[(String, Url)],
persons: &Entity<PersonRegistry>, persons: &Entity<PersonRegistry>,
cx: &App, cx: &App,
) -> RenderedText { ) -> RenderedText {
let replacements = mentions let mut replacements = mentions
.iter() .iter()
.map(|mention| { .map(|mention| {
InlineReplacement::new( InlineReplacement::new(
@@ -21,5 +22,62 @@ pub fn rendered_text(
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
replacements.extend(emoji_replacements(content, emojis));
replacements.sort_by_key(|replacement| replacement.range.start);
RenderedText::new(content, &replacements) RenderedText::new(content, &replacements)
} }
fn emoji_replacements(content: &str, emojis: &[(String, Url)]) -> Vec<InlineReplacement> {
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);
}
}
+7 -1
View File
@@ -3,7 +3,7 @@ use std::time::Duration;
use anyhow::{Result, anyhow, bail}; use anyhow::{Result, anyhow, bail};
use chat::Message; 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 nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
@@ -31,6 +31,7 @@ enum Absorbed {
pub struct Reaction { pub struct Reaction {
pub id: EventId, pub id: EventId,
pub emoji: String, pub emoji: String,
pub image: Option<SharedUri>,
pub author: PublicKey, pub author: PublicKey,
} }
@@ -756,9 +757,14 @@ impl Group {
return false; 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 { self.reactions.entry(target).or_default().push(Reaction {
id: event.id, id: event.id,
emoji: event.content.clone(), emoji: event.content.clone(),
image,
author: event.pubkey, author: event.pubkey,
}); });
+9 -1
View File
@@ -886,7 +886,15 @@ impl GroupPanel {
let content = self let content = self
.rendered_texts_by_id .rendered_texts_by_id
.entry(message.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); .element(ix.into(), window, cx);
let admin = nostr let admin = nostr
+7 -3
View File
@@ -43,9 +43,13 @@ pub(crate) fn render(
let author = persons.read(cx).get(&message.author, cx); let author = persons.read(cx).get(&message.author, cx);
let reaction_groups = group_reactions( let reaction_groups = group_reactions(
reactions reactions.iter().map(|reaction| {
.iter() (
.map(|reaction| (SharedString::from(reaction.emoji.as_str()), reaction.author)), SharedString::from(reaction.emoji.as_str()),
reaction.image.clone(),
reaction.author,
)
}),
nostr.read(cx).current_user(), nostr.read(cx).current_user(),
); );
+28 -7
View File
@@ -1,8 +1,8 @@
use std::ops::Range; use std::ops::Range;
use gpui::{ use gpui::{
AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, StyleRefinement, AnyElement, App, ElementId, HighlightStyle, IntoElement, SharedString, SharedUri,
Styled as _, Window, StyleRefinement, Styled as _, Window,
}; };
use gpui_base::{TextView, TextViewStyle}; use gpui_base::{TextView, TextViewStyle};
use theme::ActiveTheme as _; use theme::ActiveTheme as _;
@@ -12,6 +12,8 @@ use theme::ActiveTheme as _;
pub struct InlineReplacement { pub struct InlineReplacement {
pub range: Range<usize>, pub range: Range<usize>,
pub text: SharedString, pub text: SharedString,
/// When set, the span renders as this image (NIP-30 custom emoji)
pub image: Option<SharedUri>,
} }
impl InlineReplacement { impl InlineReplacement {
@@ -19,6 +21,19 @@ impl InlineReplacement {
Self { Self {
range, range,
text: text.into(), text: text.into(),
image: None,
}
}
pub fn image(
range: Range<usize>,
alt: impl Into<SharedString>,
url: impl Into<SharedUri>,
) -> 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(&content[last..range.start]);
text.push_str(&format!( match &replacement.image {
"[{}](<{}>)", Some(url) => text.push_str(&format!(
escape_link_text(&replacement.text), "![{}](<{url}>)",
&content[range.start..range.end] escape_link_text(&replacement.text)
)); )),
None => text.push_str(&format!(
"[{}](<{}>)",
escape_link_text(&replacement.text),
&content[range.start..range.end]
)),
}
last = range.end; last = range.end;
} }
text.push_str(&content[last..]); text.push_str(&content[last..]);
+19 -6
View File
@@ -370,26 +370,36 @@ impl RenderOnce for MessageMedia {
/// Reactions of one emoji with the count of authors and whether the current user reacted. /// Reactions of one emoji with the count of authors and whether the current user reacted.
pub struct ReactionGroup { pub struct ReactionGroup {
pub emoji: SharedString, pub emoji: SharedString,
/// Image of a custom emoji (NIP-30) reaction
pub image: Option<SharedUri>,
pub count: usize, pub count: usize,
pub mine: bool, 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<A: PartialEq>( pub fn group_reactions<A: PartialEq>(
reactions: impl IntoIterator<Item = (SharedString, A)>, reactions: impl IntoIterator<Item = (SharedString, Option<SharedUri>, A)>,
me: Option<A>, me: Option<A>,
) -> Vec<ReactionGroup> { ) -> Vec<ReactionGroup> {
let mut groups: BTreeMap<SharedString, (usize, bool)> = BTreeMap::new(); let mut groups: BTreeMap<SharedString, (usize, bool, Option<SharedUri>)> = BTreeMap::new();
for (emoji, author) in reactions { for (emoji, image, author) in reactions {
let group = groups.entry(emoji).or_default(); let group = groups.entry(emoji).or_default();
group.0 += 1; group.0 += 1;
group.1 |= me.as_ref() == Some(&author); group.1 |= me.as_ref() == Some(&author);
if group.2.is_none() {
group.2 = image;
}
} }
groups groups
.into_iter() .into_iter()
.map(|(emoji, (count, mine))| ReactionGroup { emoji, count, mine }) .map(|(emoji, (count, mine, image))| ReactionGroup {
emoji,
image,
count,
mine,
})
.collect() .collect()
} }
@@ -438,7 +448,10 @@ impl RenderOnce for ReactionBar {
.border_color(cx.theme().element_active) .border_color(cx.theme().element_active)
}) })
.when(!group.mine, |this| this.border_color(cx.theme().border)) .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())) .child(SharedString::from(group.count.to_string()))
})) }))
} }