From 8a043b8d494230af013cd66e766d5f4cfccc44c4 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 11 Oct 2026 16:20:20 +0700 Subject: [PATCH 1/4] add plan --- docs/NIP-30.md | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 docs/NIP-30.md diff --git a/docs/NIP-30.md b/docs/NIP-30.md new file mode 100644 index 00000000..a53d02f7 --- /dev/null +++ b/docs/NIP-30.md @@ -0,0 +1,108 @@ +# NIP-30: Custom Emoji + +Plan for adding NIP-30 support. NIP-30 is display-side: events carry +`["emoji", , , ?]` 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 (`![alt](url)`) 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` 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 + `![shortcode]()` 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` 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 (`![alt]()`, 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. -- 2.54.0 From 6dad27469d0d001e71f0479af9adba49dabea808 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 11 Oct 2026 16:43:51 +0700 Subject: [PATCH 2/4] 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())) })) } -- 2.54.0 From c87c7894391012900f64b1174918d8a444089281 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 11 Oct 2026 18:11:23 +0700 Subject: [PATCH 3/4] Support sending custom emoji in messages and reactions --- Cargo.lock | 2 + crates/chat/src/message.rs | 44 +--------- crates/chat/src/room.rs | 5 +- crates/chat_ui/src/actions.rs | 2 +- crates/chat_ui/src/lib.rs | 26 ++++-- crates/common/src/event.rs | 50 ++++++++++++ crates/nip29/Cargo.toml | 2 + crates/nip29/src/group.rs | 16 +++- crates/nip29/src/protocol.rs | 11 ++- crates/nip29_ui/src/message.rs | 27 ++++++- crates/person/src/lib.rs | 142 +++++++++++++++++++++++++++++---- crates/person/src/person.rs | 36 ++++++++- docs/NIP-30.md | 108 ------------------------- 13 files changed, 288 insertions(+), 183 deletions(-) delete mode 100644 docs/NIP-30.md diff --git a/Cargo.lock b/Cargo.lock index e228ae33..630d79fc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index 51bea6fc..a80d2513 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -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 { 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( diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index cecf1984..3b2cdb0c 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -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 diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index 091b3653..b1c47642 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -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, diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index d5df444f..c90a30e9 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -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 = 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 = + 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) -> impl IntoElement { + fn render_emoji_menu(&self, _window: &Window, cx: &Context) -> 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))) }) }) } diff --git a/crates/common/src/event.rs b/crates/common/src/event.rs index e1157751..b1fd1e74 100644 --- a/crates/common/src/event.rs +++ b/crates/common/src/event.rs @@ -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() + ); + } +} diff --git a/crates/nip29/Cargo.toml b/crates/nip29/Cargo.toml index 48332da4..bbc329d3 100644 --- a/crates/nip29/Cargo.toml +++ b/crates/nip29/Cargo.toml @@ -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 diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index 3d53ec8e..147173dc 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -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, cx: &mut Context, ) -> Task> { - 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, ) -> Task> { - 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())); diff --git a/crates/nip29/src/protocol.rs b/crates/nip29/src/protocol.rs index bad8c22d..0d680229 100644 --- a/crates/nip29/src/protocol.rs +++ b/crates/nip29/src/protocol.rs @@ -110,18 +110,27 @@ impl GroupId { &self, content: &str, replies: &[EventId], + emojis: &[Tag], previous: Option, ) -> 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) -> EventBuilder { + pub fn reaction( + &self, + target: EventId, + emoji: &str, + emojis: &[Tag], + previous: Option, + ) -> EventBuilder { EventBuilder::new(Kind::Reaction, emoji) .tag(self.h_tag()) .tag(Tag::event(target)) + .tags(emojis.iter().cloned()) .tag_maybe(previous) } diff --git a/crates/nip29_ui/src/message.rs b/crates/nip29_ui/src/message.rs index 073cd7ae..ea7edcf4 100644 --- a/crates/nip29_ui/src/message.rs +++ b/crates/nip29_ui/src/message.rs @@ -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, panel: WeakEntity, 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}"); + } + }), + ) }) } diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index d4eb870b..69e380e5 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -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); 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>, - /// Set of public keys that have been seen seen: RwLock>, - - /// Sender for requesting metadata sender: flume::Sender, - - /// 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) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + type EmojiLists = Vec<(PublicKey, Vec<(String, Url)>)>; + + let task: Task> = cx.background_spawn(async move { + let filter = Filter::new().kind(KIND_EMOJIS).limit(200); + let mut events: Vec = + 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) { + 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, cx: &mut Context) { 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 { + 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 diff --git a/crates/person/src/person.rs b/crates/person/src/person.rs index 7577ddb7..b88548c6 100644 --- a/crates/person/src/person.rs +++ b/crates/person/src/person.rs @@ -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, - /// Messaging relays messaging_relays: Vec, } @@ -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 { + 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 { self.announcement.clone() diff --git a/docs/NIP-30.md b/docs/NIP-30.md deleted file mode 100644 index a53d02f7..00000000 --- a/docs/NIP-30.md +++ /dev/null @@ -1,108 +0,0 @@ -# NIP-30: Custom Emoji - -Plan for adding NIP-30 support. NIP-30 is display-side: events carry -`["emoji", , , ?]` 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 (`![alt](url)`) 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` 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 - `![shortcode]()` 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` 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 (`![alt]()`, 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. -- 2.54.0 From 480178c5a85bf0e606335fc618c43aecfbab3b37 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 11 Oct 2026 20:16:31 +0700 Subject: [PATCH 4/4] clean up --- crates/person/src/lib.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index 69e380e5..91e035e2 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -18,9 +18,6 @@ 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); impl Global for GlobalPersonRegistry {} @@ -58,9 +55,9 @@ impl PersonRegistry { /// Create a new person registry instance fn new(cx: &mut Context) -> Self { - let entity = cx.entity().downgrade(); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); + let entity = cx.entity().downgrade(); // Channel for communication between nostr and gpui let (tx, rx) = flume::bounded::(100); @@ -238,7 +235,7 @@ impl PersonRegistry { type EmojiLists = Vec<(PublicKey, Vec<(String, Url)>)>; let task: Task> = cx.background_spawn(async move { - let filter = Filter::new().kind(KIND_EMOJIS).limit(200); + let filter = Filter::new().kind(Kind::Emojis).limit(200); let mut events: Vec = client.database().query(filter).await?.into_iter().collect(); @@ -277,7 +274,7 @@ impl PersonRegistry { 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); + 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}"); -- 2.54.0