feat: add support for custom emojis (NIP-30) (#63)

Reviewed-on: reya/coop#63
This commit is contained in:
2026-10-11 13:22:53 +00:00
parent b4ce9368e2
commit 291471075b
16 changed files with 493 additions and 63 deletions
Generated
+2
View File
@@ -4515,11 +4515,13 @@ version = "1.0.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chat", "chat",
"common",
"flume 0.11.1", "flume 0.11.1",
"futures", "futures",
"gpui-pre", "gpui-pre",
"log", "log",
"nostr-sdk", "nostr-sdk",
"person",
"serde_json", "serde_json",
"smallvec", "smallvec",
"state", "state",
+59 -1
View File
@@ -1,7 +1,7 @@
use std::hash::Hash; use std::hash::Hash;
use std::ops::Range; use std::ops::Range;
use common::{EventExt, NostrParser, extract_and_remove_media_urls}; use common::{EventExt, NostrParser, emojis_from_tags, extract_and_remove_media_urls};
use gpui::{SharedString, SharedUri}; use gpui::{SharedString, SharedUri};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use state::FileAttachment; use state::FileAttachment;
@@ -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 = 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,13 @@ impl Message {
self.content.clone().into() self.content.clone().into()
} }
/// 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 +237,50 @@ 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 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);
}
}
+4 -1
View File
@@ -405,8 +405,11 @@ impl Room {
// Construct a direct message rumor event // Construct a direct message rumor event
// WARNING: never sign and send this event to relays // WARNING: never sign and send this event to relays
let mut tags = self.conversation_tags(&replies, sender, cx);
tags.extend(PersonRegistry::emoji_tags(&content, cx));
let mut event = EventBuilder::new(kind, content) let mut event = EventBuilder::new(kind, content)
.tags(self.conversation_tags(&replies, sender, cx)) .tags(tags)
.finalize_unsigned(sender); .finalize_unsigned(sender);
// Ensure that the ID is set // Ensure that the ID is set
+1 -1
View File
@@ -6,7 +6,7 @@ use settings::SignerKind;
#[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = chat, no_json)] #[action(namespace = chat, no_json)]
pub enum Command { pub enum Command {
Insert(&'static str), Insert(String),
ChangeSubject(String), ChangeSubject(String),
ChangeSigner(SignerKind), ChangeSigner(SignerKind),
ToggleBackup, ToggleBackup,
+40 -16
View File
@@ -5,14 +5,15 @@ use std::sync::{Arc, LazyLock, RwLock};
pub use actions::*; pub use actions::*;
use anyhow::Error; use anyhow::Error;
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport}; use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport};
use common::TimestampExt; use common::{TimestampExt, emojis_from_tags};
use gpui::prelude::FluentBuilder; 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::*;
@@ -52,6 +53,10 @@ const PRIVATE_NOTICE: &str =
static EMOJI_RE: LazyLock<Regex> = static EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap()); LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap());
/// Regex matching a single custom emoji reference, like `:soapbox:`
static CUSTOM_EMOJI_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^:[A-Za-z0-9_-]+:$").unwrap());
mod actions; mod actions;
mod file; mod file;
pub mod text; pub mod text;
@@ -78,7 +83,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>,
@@ -330,7 +335,7 @@ impl ChatPanel {
// If replying to exactly one message with only a valid emoji, // If replying to exactly one message with only a valid emoji,
// send as a reaction instead of a text message // send as a reaction instead of a text message
if replies.len() == 1 if replies.len() == 1
&& EMOJI_RE.is_match(&content) && (EMOJI_RE.is_match(&content) || CUSTOM_EMOJI_RE.is_match(&content))
&& self.attachments.read(cx).is_empty() && self.attachments.read(cx).is_empty()
&& files.is_empty() && files.is_empty()
{ {
@@ -535,11 +540,16 @@ impl ChatPanel {
return; return;
} }
let emojis = 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 +573,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 +1091,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);
@@ -1741,15 +1757,23 @@ impl ChatPanel {
}) })
} }
fn render_emoji_menu(&self, _window: &Window, _cx: &Context<Self>) -> impl IntoElement { fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
let emojis = PersonRegistry::current_emojis(cx);
Button::new("emoji") Button::new("emoji")
.icon(IconName::Emoji) .icon(IconName::Emoji)
.ghost() .ghost()
.large() .large()
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| { .dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
let menu = this.horizontal(); let menu = REACTION_EMOJIS
REACTION_EMOJIS.iter().fold(menu, |this, emoji| { .iter()
this.menu(*emoji, Box::new(Command::Insert(emoji))) .fold(this.horizontal(), |this, emoji| {
this.menu(*emoji, Box::new(Command::Insert(emoji.to_string())))
});
emojis.iter().fold(menu, |this, (shortcode, _)| {
let label = format!(":{shortcode}:");
this.menu(label.clone(), Box::new(Command::Insert(label)))
}) })
}) })
} }
+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);
}
}
+50
View File
@@ -51,3 +51,53 @@ impl EventExt for UnsignedEvent {
public_keys.into_iter().unique().sorted().collect() public_keys.into_iter().unique().sorted().collect()
} }
} }
/// 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
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emojis_from_tags_parses_and_deduplicates() {
let tags = Tags::from_list(vec![
Tag::parse(vec!["emoji", "soapbox", "https://example.com/soapbox.png"]).unwrap(),
Tag::parse(vec!["emoji", "soapbox", "https://example.com/other.png"]).unwrap(),
Tag::parse(vec![
"emoji",
"bad shortcode",
"https://example.com/bad.png",
])
.unwrap(),
]);
let emojis = 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()
);
}
}
+2
View File
@@ -10,6 +10,8 @@ anyhow.workspace = true
futures.workspace = true futures.workspace = true
serde_json.workspace = true serde_json.workspace = true
state = { path = "../state" } state = { path = "../state" }
common = { path = "../common" }
person = { path = "../person" }
chat = { path = "../chat" } chat = { path = "../chat" }
gpui.workspace = true gpui.workspace = true
+19 -3
View File
@@ -3,8 +3,10 @@ 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 common::emojis_from_tags;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
use state::NostrRegistry; use state::NostrRegistry;
@@ -31,6 +33,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,
} }
@@ -233,7 +236,11 @@ impl Group {
replies: Vec<EventId>, replies: Vec<EventId>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.message(content, &replies, prev), cx) let emojis = PersonRegistry::emoji_tags(content, cx);
self.dispatch(
move |id, prev| id.message(content, &replies, &emojis, prev),
cx,
)
} }
pub fn send_reaction( pub fn send_reaction(
@@ -242,7 +249,11 @@ impl Group {
emoji: String, emoji: String,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Task<Result<()>> { ) -> Task<Result<()>> {
self.dispatch(move |id, prev| id.reaction(target, &emoji, prev), cx) let emojis = PersonRegistry::emoji_tags(&emoji, cx);
self.dispatch(
move |id, prev| id.reaction(target, &emoji, &emojis, prev),
cx,
)
} }
pub fn remove_user( pub fn remove_user(
@@ -756,9 +767,14 @@ impl Group {
return false; return false;
} }
let emojis = 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,
}); });
+10 -1
View File
@@ -110,18 +110,27 @@ impl GroupId {
&self, &self,
content: &str, content: &str,
replies: &[EventId], replies: &[EventId],
emojis: &[Tag],
previous: Option<Tag>, previous: Option<Tag>,
) -> EventBuilder { ) -> EventBuilder {
EventBuilder::new(Kind::ChatMessage, content) EventBuilder::new(Kind::ChatMessage, content)
.tag(self.h_tag()) .tag(self.h_tag())
.tags(replies.iter().copied().map(Tag::event)) .tags(replies.iter().copied().map(Tag::event))
.tags(emojis.iter().cloned())
.tag_maybe(previous) .tag_maybe(previous)
} }
pub fn reaction(&self, target: EventId, emoji: &str, previous: Option<Tag>) -> EventBuilder { pub fn reaction(
&self,
target: EventId,
emoji: &str,
emojis: &[Tag],
previous: Option<Tag>,
) -> EventBuilder {
EventBuilder::new(Kind::Reaction, emoji) EventBuilder::new(Kind::Reaction, emoji)
.tag(self.h_tag()) .tag(self.h_tag())
.tag(Tag::event(target)) .tag(Tag::event(target))
.tags(emojis.iter().cloned())
.tag_maybe(previous) .tag_maybe(previous)
} }
+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
+32 -5
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(),
); );
@@ -100,7 +104,7 @@ pub(crate) fn render(
let group = group.clone(); let group = group.clone();
let panel = panel.clone(); let panel = panel.clone();
move |menu, _window, _cx| react_menu(menu, group.clone(), panel.clone(), id) move |menu, _window, cx| react_menu(menu, group.clone(), panel.clone(), id, cx)
}); });
if !admin { if !admin {
@@ -140,8 +144,9 @@ fn react_menu(
group: WeakEntity<Group>, group: WeakEntity<Group>,
panel: WeakEntity<GroupPanel>, panel: WeakEntity<GroupPanel>,
id: EventId, id: EventId,
cx: &App,
) -> PopupMenu { ) -> PopupMenu {
REACTIONS.iter().fold(menu, |menu, emoji| { let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
let emoji = *emoji; let emoji = *emoji;
let group = group.clone(); let group = group.clone();
let panel = panel.clone(); let panel = panel.clone();
@@ -155,6 +160,28 @@ fn react_menu(
} }
}), }),
) )
});
let emojis = PersonRegistry::current_emojis(cx);
if emojis.is_empty() {
return menu;
}
let menu = menu.separator();
emojis.into_iter().fold(menu, |menu, (shortcode, _)| {
let emoji = format!(":{shortcode}:");
let group = group.clone();
let panel = panel.clone();
menu.item(
PopupMenuItem::new(emoji.clone()).on_click(move |_event, _window, cx| {
if let Err(error) = panel.update_in(cx, |panel, window, cx| {
panel.react(group.clone(), id, emoji.clone(), window, cx);
}) {
log::warn!("nip29: sending a reaction failed: {error}");
}
}),
)
}) })
} }
+125 -16
View File
@@ -1,9 +1,10 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::sync::RwLock; use std::sync::RwLock;
use anyhow::{Error, anyhow}; use anyhow::{Error, anyhow};
use common::EventExt; use common::{EventExt, emojis_from_tags};
use gpui::{App, AppContext, Context, Entity, Global, Task}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use instant::Duration; use instant::Duration;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec}; use smallvec::{SmallVec, smallvec};
@@ -26,6 +27,7 @@ enum Dispatch {
Person(Person), Person(Person),
Announcement(Event), Announcement(Event),
Relays(Event), Relays(Event),
Emojis(Event),
} }
/// Person Registry /// Person Registry
@@ -33,15 +35,11 @@ enum Dispatch {
pub struct PersonRegistry { pub struct PersonRegistry {
/// Collection of all persons (user profiles) /// Collection of all persons (user profiles)
persons: HashMap<PublicKey, Entity<Person>>, persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen /// Set of public keys that have been seen
seen: RwLock<HashSet<PublicKey>>, seen: RwLock<HashSet<PublicKey>>,
/// Sender for requesting metadata
sender: flume::Sender<PublicKey>, sender: flume::Sender<PublicKey>,
/// Tasks for asynchronous operations
tasks: SmallVec<[Task<()>; 4]>, tasks: SmallVec<[Task<()>; 4]>,
_subscriptions: SmallVec<[Subscription; 1]>,
} }
impl PersonRegistry { impl PersonRegistry {
@@ -57,9 +55,9 @@ impl PersonRegistry {
/// Create a new person registry instance /// Create a new person registry instance
fn new(cx: &mut Context<Self>) -> Self { fn new(cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let nostr = NostrRegistry::global(cx); let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client(); let client = nostr.read(cx).client();
let entity = cx.entity().downgrade();
// Channel for communication between nostr and gpui // Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100); let (tx, rx) = flume::bounded::<Dispatch>(100);
@@ -90,15 +88,27 @@ impl PersonRegistry {
Dispatch::Relays(event) => { Dispatch::Relays(event) => {
this.set_messaging_relays(&event, cx); this.set_messaging_relays(&event, cx);
} }
Dispatch::Emojis(event) => {
this.set_emojis(&event, cx);
}
}; };
}) })
.ok(); .ok();
} }
})); }));
let subscription = cx.subscribe(&nostr, |this, _nostr, event, cx| {
if event.signer_changed() {
this.get_emojis(cx);
}
});
// Load all user profiles from the database // Load all user profiles from the database
cx.defer(move |cx| { cx.defer(move |cx| {
entity.update(cx, |this, cx| this.load(cx)).ok(); _ = entity.update(cx, |this, cx| {
this.load(cx);
this.load_emojis(cx);
});
}); });
Self { Self {
@@ -106,6 +116,7 @@ impl PersonRegistry {
seen: RwLock::new(HashSet::new()), seen: RwLock::new(HashSet::new()),
sender: metadata_tx, sender: metadata_tx,
tasks, tasks,
_subscriptions: smallvec![subscription],
} }
} }
@@ -130,8 +141,9 @@ impl PersonRegistry {
Kind::Metadata => { Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default(); let metadata = Metadata::from_json(&event.content).unwrap_or_default();
let person = Person::new(event.pubkey, metadata); let person = Person::new(event.pubkey, metadata);
if tx.send_async(Dispatch::Person(person)).await.is_err() { if tx.send_async(Dispatch::Person(person)).await.is_err() {
log::warn!("PersonRegistry channel closed, dropping metadata event"); log::warn!("Channel closed, dropping metadata event");
} }
} }
Kind::ContactList => { Kind::ContactList => {
@@ -141,14 +153,15 @@ impl PersonRegistry {
} }
} }
Kind::InboxRelays => { Kind::InboxRelays => {
tx.send_async(Dispatch::Relays(event.into_owned())) _ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
.await }
.ok(); Kind::Emojis => {
_ = tx.send_async(Dispatch::Emojis(event.into_owned())).await;
} }
Kind::Custom(10044) => { Kind::Custom(10044) => {
tx.send_async(Dispatch::Announcement(event.into_owned())) _ = tx
.await .send_async(Dispatch::Announcement(event.into_owned()))
.ok(); .await;
} }
_ => {} _ => {}
} }
@@ -214,6 +227,61 @@ impl PersonRegistry {
})); }));
} }
/// Load cached emoji lists from the database
fn load_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
type EmojiLists = Vec<(PublicKey, Vec<(String, Url)>)>;
let task: Task<Result<EmojiLists, Error>> = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Emojis).limit(200);
let mut events: Vec<Event> =
client.database().query(filter).await?.into_iter().collect();
// Keep only the newest list per author
events.sort_by_key(|event| Reverse(event.created_at));
let mut seen = HashSet::new();
let lists = events
.into_iter()
.filter(|event| seen.insert(event.pubkey))
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
.collect();
Ok(lists)
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(lists) = task.await {
this.update(cx, |this, cx| {
for (public_key, emojis) in lists {
this.set_emojis_for(public_key, emojis, cx);
}
})
.ok();
}
}));
}
/// Subscribe to the signed-in user's emoji list
fn get_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let Some(public_key) = nostr.read(cx).current_user() else {
return;
};
self.tasks.push(cx.background_spawn(async move {
let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE);
let filter = Filter::new().kind(Kind::Emojis).author(public_key).limit(1);
if let Err(e) = client.subscribe(vec![filter]).close_on(opts).await {
log::warn!("Failed to subscribe to emoji list: {e}");
}
}));
}
/// Set profile encryption keys announcement /// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut App) { fn set_announcement(&mut self, event: &Event, cx: &mut App) {
let Some(announcement) = Announcement::from_event(event) else { let Some(announcement) = Announcement::from_event(event) else {
@@ -248,6 +316,24 @@ impl PersonRegistry {
} }
} }
/// Set custom emoji for a person from their emoji list event
fn set_emojis(&mut self, event: &Event, cx: &mut App) {
self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx);
}
/// Set custom emoji for a person
fn set_emojis_for(&mut self, public_key: PublicKey, emojis: Vec<(String, Url)>, cx: &mut App) {
if let Some(person) = self.persons.get(&public_key) {
person.update(cx, |person, cx| {
person.set_emojis(emojis);
cx.notify();
});
} else {
let person = Person::new(public_key, Metadata::default()).with_emojis(emojis);
self.insert(person, cx);
}
}
/// Insert batch of persons /// Insert batch of persons
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) { fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() { for person in persons.into_iter() {
@@ -299,6 +385,29 @@ impl PersonRegistry {
// Return a temporary profile with default metadata // Return a temporary profile with default metadata
Person::new(public_key, Metadata::default()) Person::new(public_key, Metadata::default())
} }
/// The signed-in user's custom emoji
pub fn current_emojis(cx: &App) -> Vec<(String, Url)> {
let registry = Self::global(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return Vec::new();
};
registry.read(cx).get(&public_key, cx).emojis().to_vec()
}
/// An `emoji` tag for every signed-in user's custom emoji referenced.
pub fn emoji_tags(content: &str, cx: &App) -> Vec<Tag> {
let registry = Self::global(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return Vec::new();
};
registry.read(cx).get(&public_key, cx).emoji_tags(content)
}
} }
/// Get metadata for all public keys in a event /// Get metadata for all public keys in a event
+33 -3
View File
@@ -10,13 +10,12 @@ use state::Announcement;
pub struct Person { pub struct Person {
/// Public Key /// Public Key
public_key: PublicKey, public_key: PublicKey,
/// Metadata (profile) /// Metadata (profile)
metadata: Metadata, metadata: Metadata,
/// Custom emoji (NIP-30) from the person's emoji list
emojis: Vec<(String, Url)>,
/// Dekey (NIP-4e) announcement /// Dekey (NIP-4e) announcement
announcement: Option<Announcement>, announcement: Option<Announcement>,
/// Messaging relays /// Messaging relays
messaging_relays: Vec<RelayUrl>, messaging_relays: Vec<RelayUrl>,
} }
@@ -52,6 +51,7 @@ impl Person {
Self { Self {
public_key, public_key,
metadata, metadata,
emojis: vec![],
announcement: None, announcement: None,
messaging_relays: vec![], messaging_relays: vec![],
} }
@@ -82,6 +82,36 @@ impl Person {
self.metadata.clone() self.metadata.clone()
} }
/// Custom emoji (NIP-30) as (shortcode, image url) pairs
pub fn emojis(&self) -> &[(String, Url)] {
&self.emojis
}
/// Set custom emoji from the person's emoji list
pub(crate) fn set_emojis(&mut self, emojis: Vec<(String, Url)>) {
self.emojis = emojis;
}
/// Build custom emoji from the person's emoji list
pub(crate) fn with_emojis(mut self, emojis: Vec<(String, Url)>) -> Self {
self.emojis = emojis;
self
}
/// An `emoji` tag for every custom emoji referenced as `:shortcode:` in `content`
pub fn emoji_tags(&self, content: &str) -> Vec<Tag> {
let mut tags = Vec::new();
for (shortcode, url) in &self.emojis {
let pattern = format!(":{shortcode}:");
if content.contains(&pattern) {
tags.push(Tag::custom("emoji", [shortcode.clone(), url.to_string()]));
}
}
tags
}
/// Get profile encryption keys announcement /// Get profile encryption keys announcement
pub fn announcement(&self) -> Option<Announcement> { pub fn announcement(&self) -> Option<Announcement> {
self.announcement.clone() self.announcement.clone()
+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()))
})) }))
} }