5.9 KiB
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) shipsnostr::nips::nip30::Nip30Tagwithparse()/TryFrom<&Tag>, shortcode validation, and the optional emoji-set coordinate.nip51::Emojis(kind 10030) andKind::EmojiSet(30030) cover the sending side. - gpui-base 0.7.1 renders inline markdown images (
) atline_height * 0.75when 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
- Carry emoji tags on messages (
crates/chat/src/message.rs): addemojis: Vec<Nip30Tag>toMessage; parse infrom_partsalongside mentions, skip invalid tags, dedupe by shortcode (first wins). - Image replacements in the markdown pipeline
(
crates/ui/src/markdown.rs): extendInlineReplacement(enum:Linkas today, or a siblingImage { alt, url }) soRenderedText::newcan spliceinstead of a link. Reuse the existing range validation; URLs fromnostr::types::Urlare already well-formed, and an http(s)-only check (likeis_web_url) is a cheap extra guard. - Image-aware reactions (
crates/ui/src/message.rs): addimage: Option<SharedUri>toReactionGroup, letgroup_reactionstake an image resolver, and renderimg(...)at caption glyph size inReactionBarwhen set, text otherwise. - Emojify message text (
crates/chat_ui/src/text.rs):rendered_textgains 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 like12:30:45), merged with mention replacements sorted by range. Update the tworendered_texts_by_idcall sites inChatPanel::render_rowandGroupPanel::render_row. - Wire reactions:
ChatPanel::insert_reactionkeeps 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; updatecrates/nip29_ui/src/message.rs. - Edits (concord
KIND_EDIT): recompute rendered text with the edit event's own emoji tags wherever content replacement invalidatesrendered_texts_by_id.
Phase 2 — Send custom emoji (optional)
- Emoji source: subscribe to the user's kind 10030 emoji list
(
nip51::Emojis) and/or kind 30030 sets; resolve pointed sets on demand. - Reactions: extend
GroupId::reaction(crates/nip29/src/protocol.rs) and the DM reaction builder (crates/chat/src/room.rs::rumor) to appendemojitags; list custom emoji in the React submenus, sending:shortcode:content plus tags.EMOJI_REincrates/chat_ui/src/lib.rsclassifies emoji-only replies as reactions and won't match:foo:— the regex or the classification needs updating. - Composer: offer custom emoji in
render_emoji_menu(insert:shortcode:); on send, attach matchingemojitags for shortcodes found in the content.
Phase 3 — Kind 0 profiles (optional)
- Parse emoji tags from the kind 0 event when constructing
Personand store the shortcode→url map. - Emojify
name/aboutwhere displayed.Person::name()returns a plainSharedStringused in dozens of plain-text sites, so start with an element-returning name variant forMessageRow'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;RenderedTextsplicing (, overlap with mentions, unknown shortcodes untouched);group_reactionswith images. - Manual: inject a kind 7 with
:shortcode:content plus an emoji tag (e.g. vianak event) into a DM and an open group; verify the image renders in the reaction bar and message body, and that:unknown:and12:34render literally. cargo test -p chat -p uiplus 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.