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
+22 -10
View File
@@ -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<EventId, usize>,
/// All reactions
reactions: BTreeMap<EventId, Vec<(SharedString, PublicKey)>>,
reactions: BTreeMap<EventId, Vec<(SharedString, Option<SharedUri>, PublicKey)>>,
/// Mapping message ids to their rendered texts
rendered_texts_by_id: BTreeMap<EventId, RenderedText>,
@@ -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<SharedUri>, 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);
+60 -2
View File
@@ -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<PersonRegistry>,
cx: &App,
) -> RenderedText {
let replacements = mentions
let mut replacements = mentions
.iter()
.map(|mention| {
InlineReplacement::new(
@@ -21,5 +22,62 @@ pub fn rendered_text(
})
.collect::<Vec<_>>();
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<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);
}
}