Files
coop/docs/NIP-30.md
T
2026-10-11 16:20:20 +07:00

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) 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<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 ![shortcode](<url>) 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 (![alt](<url>), 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.