Support sending custom emoji in messages and reactions
This commit is contained in:
Generated
+2
@@ -4515,11 +4515,13 @@ version = "1.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chat",
|
||||
"common",
|
||||
"flume 0.11.1",
|
||||
"futures",
|
||||
"gpui-pre",
|
||||
"log",
|
||||
"nostr-sdk",
|
||||
"person",
|
||||
"serde_json",
|
||||
"smallvec",
|
||||
"state",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::hash::Hash;
|
||||
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 nostr_sdk::prelude::*;
|
||||
use state::FileAttachment;
|
||||
@@ -87,7 +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);
|
||||
let emojis = emojis_from_tags(tags);
|
||||
|
||||
// For file messages `.content` is the encrypted blob URL, not text or media
|
||||
let mentions = if has_file {
|
||||
@@ -151,28 +151,6 @@ 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(':')?;
|
||||
@@ -278,24 +256,6 @@ mod tests {
|
||||
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(
|
||||
|
||||
@@ -405,8 +405,11 @@ impl Room {
|
||||
|
||||
// Construct a direct message rumor event
|
||||
// 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)
|
||||
.tags(self.conversation_tags(&replies, sender, cx))
|
||||
.tags(tags)
|
||||
.finalize_unsigned(sender);
|
||||
|
||||
// Ensure that the ID is set
|
||||
|
||||
@@ -6,7 +6,7 @@ use settings::SignerKind;
|
||||
#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[action(namespace = chat, no_json)]
|
||||
pub enum Command {
|
||||
Insert(&'static str),
|
||||
Insert(String),
|
||||
ChangeSubject(String),
|
||||
ChangeSigner(SignerKind),
|
||||
ToggleBackup,
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::sync::{Arc, LazyLock, RwLock};
|
||||
pub use actions::*;
|
||||
use anyhow::Error;
|
||||
use chat::{ChatRegistry, Message, Room, RoomEvent, SendReport};
|
||||
use common::TimestampExt;
|
||||
use common::{TimestampExt, emojis_from_tags};
|
||||
use gpui::prelude::FluentBuilder;
|
||||
use gpui::{
|
||||
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
|
||||
@@ -53,6 +53,10 @@ const PRIVATE_NOTICE: &str =
|
||||
static EMOJI_RE: LazyLock<Regex> =
|
||||
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 file;
|
||||
pub mod text;
|
||||
@@ -331,7 +335,7 @@ impl ChatPanel {
|
||||
// If replying to exactly one message with only a valid emoji,
|
||||
// send as a reaction instead of a text message
|
||||
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()
|
||||
&& files.is_empty()
|
||||
{
|
||||
@@ -536,7 +540,7 @@ impl ChatPanel {
|
||||
return;
|
||||
}
|
||||
|
||||
let emojis = Message::emojis_from_tags(&event.tags);
|
||||
let emojis = emojis_from_tags(&event.tags);
|
||||
let image = Message::reaction_image(&event.content, &emojis)
|
||||
.map(|url| SharedUri::from(url.to_string()));
|
||||
|
||||
@@ -1753,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")
|
||||
.icon(IconName::Emoji)
|
||||
.ghost()
|
||||
.large()
|
||||
.dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| {
|
||||
let menu = this.horizontal();
|
||||
REACTION_EMOJIS.iter().fold(menu, |this, emoji| {
|
||||
this.menu(*emoji, Box::new(Command::Insert(emoji)))
|
||||
let menu = REACTION_EMOJIS
|
||||
.iter()
|
||||
.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)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,3 +51,53 @@ impl EventExt for UnsignedEvent {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ anyhow.workspace = true
|
||||
futures.workspace = true
|
||||
serde_json.workspace = true
|
||||
state = { path = "../state" }
|
||||
common = { path = "../common" }
|
||||
person = { path = "../person" }
|
||||
chat = { path = "../chat" }
|
||||
|
||||
gpui.workspace = true
|
||||
|
||||
@@ -3,8 +3,10 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use chat::Message;
|
||||
use common::emojis_from_tags;
|
||||
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
|
||||
use nostr_sdk::prelude::*;
|
||||
use person::PersonRegistry;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
use state::NostrRegistry;
|
||||
|
||||
@@ -234,7 +236,11 @@ impl Group {
|
||||
replies: Vec<EventId>,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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(
|
||||
@@ -243,7 +249,11 @@ impl Group {
|
||||
emoji: String,
|
||||
cx: &mut Context<Self>,
|
||||
) -> 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(
|
||||
@@ -757,7 +767,7 @@ impl Group {
|
||||
return false;
|
||||
}
|
||||
|
||||
let emojis = Message::emojis_from_tags(&event.tags);
|
||||
let emojis = emojis_from_tags(&event.tags);
|
||||
let image = Message::reaction_image(&event.content, &emojis)
|
||||
.map(|url| SharedUri::from(url.to_string()));
|
||||
|
||||
|
||||
@@ -110,18 +110,27 @@ impl GroupId {
|
||||
&self,
|
||||
content: &str,
|
||||
replies: &[EventId],
|
||||
emojis: &[Tag],
|
||||
previous: Option<Tag>,
|
||||
) -> EventBuilder {
|
||||
EventBuilder::new(Kind::ChatMessage, content)
|
||||
.tag(self.h_tag())
|
||||
.tags(replies.iter().copied().map(Tag::event))
|
||||
.tags(emojis.iter().cloned())
|
||||
.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)
|
||||
.tag(self.h_tag())
|
||||
.tag(Tag::event(target))
|
||||
.tags(emojis.iter().cloned())
|
||||
.tag_maybe(previous)
|
||||
}
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ pub(crate) fn render(
|
||||
let group = group.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 {
|
||||
@@ -144,8 +144,9 @@ fn react_menu(
|
||||
group: WeakEntity<Group>,
|
||||
panel: WeakEntity<GroupPanel>,
|
||||
id: EventId,
|
||||
cx: &App,
|
||||
) -> PopupMenu {
|
||||
REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||
let menu = REACTIONS.iter().fold(menu, |menu, emoji| {
|
||||
let emoji = *emoji;
|
||||
let group = group.clone();
|
||||
let panel = panel.clone();
|
||||
@@ -159,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}");
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+127
-15
@@ -1,9 +1,10 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::RwLock;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use common::EventExt;
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Task};
|
||||
use common::{EventExt, emojis_from_tags};
|
||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
|
||||
use instant::Duration;
|
||||
use nostr_sdk::prelude::*;
|
||||
use smallvec::{SmallVec, smallvec};
|
||||
@@ -17,6 +18,9 @@ pub fn init(cx: &mut App) {
|
||||
PersonRegistry::set_global(cx.new(PersonRegistry::new), cx);
|
||||
}
|
||||
|
||||
/// The emoji list event kind (NIP-51)
|
||||
const KIND_EMOJIS: Kind = Kind::Custom(10030);
|
||||
|
||||
struct GlobalPersonRegistry(Entity<PersonRegistry>);
|
||||
|
||||
impl Global for GlobalPersonRegistry {}
|
||||
@@ -26,6 +30,7 @@ enum Dispatch {
|
||||
Person(Person),
|
||||
Announcement(Event),
|
||||
Relays(Event),
|
||||
Emojis(Event),
|
||||
}
|
||||
|
||||
/// Person Registry
|
||||
@@ -33,15 +38,11 @@ enum Dispatch {
|
||||
pub struct PersonRegistry {
|
||||
/// Collection of all persons (user profiles)
|
||||
persons: HashMap<PublicKey, Entity<Person>>,
|
||||
|
||||
/// Set of public keys that have been seen
|
||||
seen: RwLock<HashSet<PublicKey>>,
|
||||
|
||||
/// Sender for requesting metadata
|
||||
sender: flume::Sender<PublicKey>,
|
||||
|
||||
/// Tasks for asynchronous operations
|
||||
tasks: SmallVec<[Task<()>; 4]>,
|
||||
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||
}
|
||||
|
||||
impl PersonRegistry {
|
||||
@@ -90,15 +91,27 @@ impl PersonRegistry {
|
||||
Dispatch::Relays(event) => {
|
||||
this.set_messaging_relays(&event, cx);
|
||||
}
|
||||
Dispatch::Emojis(event) => {
|
||||
this.set_emojis(&event, cx);
|
||||
}
|
||||
};
|
||||
})
|
||||
.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
|
||||
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 {
|
||||
@@ -106,6 +119,7 @@ impl PersonRegistry {
|
||||
seen: RwLock::new(HashSet::new()),
|
||||
sender: metadata_tx,
|
||||
tasks,
|
||||
_subscriptions: smallvec![subscription],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +144,9 @@ impl PersonRegistry {
|
||||
Kind::Metadata => {
|
||||
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||
let person = Person::new(event.pubkey, metadata);
|
||||
|
||||
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 => {
|
||||
@@ -141,14 +156,15 @@ impl PersonRegistry {
|
||||
}
|
||||
}
|
||||
Kind::InboxRelays => {
|
||||
tx.send_async(Dispatch::Relays(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
_ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
|
||||
}
|
||||
Kind::Emojis => {
|
||||
_ = tx.send_async(Dispatch::Emojis(event.into_owned())).await;
|
||||
}
|
||||
Kind::Custom(10044) => {
|
||||
tx.send_async(Dispatch::Announcement(event.into_owned()))
|
||||
.await
|
||||
.ok();
|
||||
_ = tx
|
||||
.send_async(Dispatch::Announcement(event.into_owned()))
|
||||
.await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -214,6 +230,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
|
||||
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
|
||||
let Some(announcement) = Announcement::from_event(event) else {
|
||||
@@ -248,6 +319,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
|
||||
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
|
||||
for person in persons.into_iter() {
|
||||
@@ -299,6 +388,29 @@ impl PersonRegistry {
|
||||
// Return a temporary profile with default metadata
|
||||
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
|
||||
|
||||
@@ -10,13 +10,12 @@ use state::Announcement;
|
||||
pub struct Person {
|
||||
/// Public Key
|
||||
public_key: PublicKey,
|
||||
|
||||
/// Metadata (profile)
|
||||
metadata: Metadata,
|
||||
|
||||
/// Custom emoji (NIP-30) from the person's emoji list
|
||||
emojis: Vec<(String, Url)>,
|
||||
/// Dekey (NIP-4e) announcement
|
||||
announcement: Option<Announcement>,
|
||||
|
||||
/// Messaging relays
|
||||
messaging_relays: Vec<RelayUrl>,
|
||||
}
|
||||
@@ -52,6 +51,7 @@ impl Person {
|
||||
Self {
|
||||
public_key,
|
||||
metadata,
|
||||
emojis: vec![],
|
||||
announcement: None,
|
||||
messaging_relays: vec![],
|
||||
}
|
||||
@@ -82,6 +82,36 @@ impl Person {
|
||||
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
|
||||
pub fn announcement(&self) -> Option<Announcement> {
|
||||
self.announcement.clone()
|
||||
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
# NIP-30: Custom Emoji
|
||||
|
||||
Plan for adding NIP-30 support. NIP-30 is display-side: events carry
|
||||
`["emoji", <shortcode>, <image-url>, <emoji-set-address>?]` tags and clients
|
||||
replace `:shortcode:` occurrences in text with the referenced image.
|
||||
Shortcodes are `[A-Za-z0-9_-]`. It applies to kind 0 (`name`/`about`), kind 1,
|
||||
kind 1111, kind 7, and kind 30315 content.
|
||||
|
||||
Two existing pieces make this cheap:
|
||||
|
||||
- The nostr SDK (locked rev `463f0c2`, v0.45.5) ships
|
||||
`nostr::nips::nip30::Nip30Tag` with `parse()`/`TryFrom<&Tag>`, shortcode
|
||||
validation, and the optional emoji-set coordinate. `nip51::Emojis` (kind
|
||||
10030) and `Kind::EmojiSet` (30030) cover the sending side.
|
||||
- gpui-base 0.7.1 renders inline markdown images (``) at
|
||||
`line_height * 0.75` when no dimensions are given — emoji scale, flowing
|
||||
with text. Remote image loading already works (`MessageMedia`, markdown
|
||||
image nodes).
|
||||
|
||||
## Current render paths
|
||||
|
||||
| Surface | Data | Render |
|
||||
|---|---|---|
|
||||
| Messages (NIP-17 DMs, concord, NIP-29) | `chat::Message` built in `crates/chat/src/message.rs::from_parts` (tags → mentions, replies, files) | `chat_ui::text::rendered_text` → `ui::markdown::RenderedText` → `TextView::markdown` |
|
||||
| Reactions | `ChatPanel::insert_reaction` stores `(content, pubkey)`; `nip29::Group::push_reaction` stores `Reaction { emoji, author }` | `ui::message::group_reactions` → `ReactionBar`, emoji as a plain text child (`crates/ui/src/message.rs`) |
|
||||
| Profiles | `person::Person` keeps only `Metadata`, no tags | `Person::name()` as plain `SharedString`; person `about` is not rendered anywhere |
|
||||
|
||||
Kind coverage in coop: kind 7 (reactions) and kind 1111 (concord comments)
|
||||
apply directly; kind 1/kind 9 message content goes through the same shared
|
||||
path. Kind 0 is names only (no `about` surface exists). Kind 30315 is out of
|
||||
scope — the app has no NIP-38 status feature.
|
||||
|
||||
## Phase 1 — Receive and display
|
||||
|
||||
1. **Carry emoji tags on messages** (`crates/chat/src/message.rs`): add
|
||||
`emojis: Vec<Nip30Tag>` to `Message`; parse in `from_parts` alongside
|
||||
mentions, skip invalid tags, dedupe by shortcode (first wins).
|
||||
2. **Image replacements in the markdown pipeline**
|
||||
(`crates/ui/src/markdown.rs`): extend `InlineReplacement` (enum: `Link` as
|
||||
today, or a sibling `Image { alt, url }`) so `RenderedText::new` can splice
|
||||
`` instead of a link. Reuse the existing range
|
||||
validation; URLs from `nostr::types::Url` are already well-formed, and an
|
||||
http(s)-only check (like `is_web_url`) is a cheap extra guard.
|
||||
3. **Image-aware reactions** (`crates/ui/src/message.rs`): add
|
||||
`image: Option<SharedUri>` to `ReactionGroup`, let `group_reactions` take
|
||||
an image resolver, and render `img(...)` at caption glyph size in
|
||||
`ReactionBar` when set, text otherwise.
|
||||
4. **Emojify message text** (`crates/chat_ui/src/text.rs`): `rendered_text`
|
||||
gains the event's emojis; scan for `:shortcode:` and emit image
|
||||
replacements only for shortcodes present in the event's tags (this alone
|
||||
prevents false positives like `12:30:45`), merged with mention
|
||||
replacements sorted by range. Update the two `rendered_texts_by_id`
|
||||
call sites in `ChatPanel::render_row` and `GroupPanel::render_row`.
|
||||
5. **Wire reactions**: `ChatPanel::insert_reaction` keeps the reaction's
|
||||
shortcode→url map per target and passes the resolver;
|
||||
`nip29::Reaction` (`crates/nip29/src/group.rs::push_reaction`) parses the
|
||||
reaction event's emoji tags; update `crates/nip29_ui/src/message.rs`.
|
||||
6. **Edits** (concord `KIND_EDIT`): recompute rendered text with the edit
|
||||
event's own emoji tags wherever content replacement invalidates
|
||||
`rendered_texts_by_id`.
|
||||
|
||||
## Phase 2 — Send custom emoji (optional)
|
||||
|
||||
1. **Emoji source**: subscribe to the user's kind 10030 emoji list
|
||||
(`nip51::Emojis`) and/or kind 30030 sets; resolve pointed sets on demand.
|
||||
2. **Reactions**: extend `GroupId::reaction` (`crates/nip29/src/protocol.rs`)
|
||||
and the DM reaction builder (`crates/chat/src/room.rs::rumor`) to append
|
||||
`emoji` tags; list custom emoji in the React submenus, sending
|
||||
`:shortcode:` content plus tags. `EMOJI_RE` in `crates/chat_ui/src/lib.rs`
|
||||
classifies emoji-only replies as reactions and won't match `:foo:` — the
|
||||
regex or the classification needs updating.
|
||||
3. **Composer**: offer custom emoji in `render_emoji_menu` (insert
|
||||
`:shortcode:`); on send, attach matching `emoji` tags for shortcodes found
|
||||
in the content.
|
||||
|
||||
## Phase 3 — Kind 0 profiles (optional)
|
||||
|
||||
- Parse emoji tags from the kind 0 event when constructing `Person` and store
|
||||
the shortcode→url map.
|
||||
- Emojify `name`/`about` where displayed. `Person::name()` returns a plain
|
||||
`SharedString` used in dozens of plain-text sites, so start with an
|
||||
element-returning name variant for `MessageRow`'s author and let plain-text
|
||||
contexts degrade to literal `:shortcode:`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- Only emojify shortcodes that have an emoji tag on the same event (spec
|
||||
requirement; also the main guard against false positives).
|
||||
- Invalid shortcodes and malformed tags are skipped (SDK validation).
|
||||
- Emojification happens pre-markdown, so a matching shortcode inside a code
|
||||
block becomes an image. Acceptable for v1.
|
||||
- Reply previews and notifications stay plain text — known limitation.
|
||||
- Image URLs are attacker-controlled but only load through GPUI's HTTP
|
||||
resource loader; keep the http(s) restriction as defense in depth.
|
||||
|
||||
## Validation
|
||||
|
||||
- Unit tests: emoji tag parsing and dedupe in `from_parts`; `RenderedText`
|
||||
splicing (``, overlap with mentions, unknown shortcodes
|
||||
untouched); `group_reactions` with images.
|
||||
- Manual: inject a kind 7 with `:shortcode:` content plus an emoji tag (e.g.
|
||||
via `nak event`) into a DM and an open group; verify the image renders in
|
||||
the reaction bar and message body, and that `:unknown:` and `12:34` render
|
||||
literally.
|
||||
- `cargo test -p chat -p ui` plus a clippy pass.
|
||||
|
||||
Phase 1 is the deliverable: roughly six files, no new dependencies. Phases 2
|
||||
and 3 are each comparable in size and can ship independently.
|
||||
Reference in New Issue
Block a user