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.