Skip to content

fix(desktop): robust emoji picker — unify picker + fix custom emoji in editing, status, reactions#837

Merged
wesbillman merged 4 commits into
mainfrom
brain/robust-emoji-picker
Jun 3, 2026
Merged

fix(desktop): robust emoji picker — unify picker + fix custom emoji in editing, status, reactions#837
wesbillman merged 4 commits into
mainfrom
brain/robust-emoji-picker

Conversation

@wesbillman

Copy link
Copy Markdown
Collaborator

Summary

Unifies the emoji picker into one component and fixes custom-emoji rendering across status, reactions, and message editing. Originally a single picker-unification change; review and testing surfaced edit-flow bugs that are fixed here too.

What changed

Picker unification — consolidated the multiple emoji-picker call sites (composer, reactions, status dialogs) into one EmojiPicker component, plus custom-emoji rendering in status indicators.

Edit-flow fixes (custom emoji)

  • Bug 1 — edit composer showed :shortcode: as text. The customEmoji Tiptap node only materialized via the live input rule; loading a message via setContent (edit-open) left shortcodes as literal text. Added a markdown-it inline rule (wired via parse.setup) so known shortcodes become image nodes on load. Unknown shortcodes stay text.
  • Bug 2 — adding an emoji while editing saved as bare :shortcode:. The send path attached NIP-30 ["emoji",…] tags; the edit path didn't. Now the edit-save path builds + routes emoji tags through a dedicated emoji_tags arg (editMessageedit_messagebuild_message_edit), validated by the existing Rust emoji guard, and overlaid by applyEditTagOverlay on both the optimistic and receiver render paths.

Review fixes (caught by adversarial second pass + Codex)

  • Tag-less / legacy edits preserve original emoji. applyEditTagOverlay now replaces emoji tags only when the edit supplies some; a tag-less edit (older build or cross-client) preserves the original's, so an unrelated text edit can't re-break a rendered :shortcode:. Strictly safe — an orphaned emoji tag whose shortcode isn't in the body resolves to nothing.
  • Native status emoji spacing. StatusEmoji's native/fallback span now threads the caller's className, matching the image branch.
  • Shortcode boundary guard. The edit-composer markdown-it rule no longer fires mid-word or inside URLs (not:sprout:, http://x:y:sprout:); boundary cases (:sprout:, :sprout:, (:sprout:)) still do.

Testing

  • 479 JS unit — incl. new cases for the tag-overlay legacy-preservation path, imeta-vs-emoji asymmetry, and the shortcode boundary guard (driving the actual registered rule).
  • New e2e (custom-emoji.spec.ts) — edit-with-emoji renders the image (Bug 1), add-emoji-on-edit survives save (Bug 2), and system-message reactions; via a new edit_message mock-bridge seam.
  • typecheck · lint · build · file-size guard · full mock-bridge e2e set (messaging, identity-archive, mentions, custom-emoji) green.

🤖 Reviewed adversarially by Pinky (second-agent pass) + Codex.

wesbillman and others added 4 commits June 3, 2026 14:18
Unify the four drifting emoji-picker instantiations behind a single
`<EmojiPicker>` that always wires the workspace custom palette internally and
normalizes selection to a `native` glyph or `:shortcode:`. This fixes the
headline bug — custom emoji could not be picked when reacting to a *system*
message, because `SystemMessageRow`'s picker had no `custom` prop and read
`emoji.native` only.

Sites swapped: composer, regular-message reactions, system-message reactions,
and the status dialog. Also drops the `customEmoji` prop-drilling chain
(MessageComposer -> toolbar -> ComposerEmojiPicker) since the picker self-fetches.

Status now accepts custom emoji everywhere. A status emoji is a bare string
(no companion emojiUrl like reactions), so it was rendered raw and a custom
`:shortcode:` would have shown as literal text in five places. Add a single
`<StatusEmoji>` that resolves a known shortcode to its workspace image via the
localhost media proxy (matching reactions' EmojiGlyph), and render it at all
five status spots: sidebar, profile popover, profile panel, user-profile
popover, and the dialog trigger.

Verified: typecheck, biome lint, vite build, and the custom-emoji e2e specs
(4 passing) — including the reaction-via-media-proxy test that exercises the
exact shortcode->emojiUrl resolution the system-message picker now shares.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Two distinct bugs when editing a message containing custom emoji:

1. The edit composer showed raw `:shortcode:` instead of the emoji image.
   The Tiptap `customEmoji` atom node only materialized via an input rule
   (live keystrokes); its markdown `parse` rule was empty, so loading a
   message via `setContent` left `:shortcode:` as plain text. Add a
   markdown-it inline rule (`registerCustomEmojiMarkdownIt`) wired through the
   node's `parse.setup` that emits `<img data-custom-emoji>` for *known*
   shortcodes — the same shape `renderHTML` produces — so the node's existing
   `parseHTML` materializes it on load. Registered once per markdown-it
   instance; matching reads the live shortcode set lazily.

2. Adding a custom emoji while editing saved as a bare `:shortcode:`.
   The send path attaches NIP-30 `["emoji", shortcode, url]` tags so the event
   is self-contained; the edit path never did, so an edited body shipped with
   no emoji tags and the receiver couldn't resolve the shortcode. Thread emoji
   tags through the whole edit path:
     - composer builds + merges them (parity with send);
     - the edit mutation splits the merged set and routes emoji to a dedicated
       arg (emoji must NOT ride the imeta-only channel — the Rust `imeta_tags`
       guard rejects non-imeta prefixes);
     - `editMessage` (tauri) + the `edit_message` command + `build_message_edit`
       gain an `emoji_tags` param, validated by the existing `emoji_tags`
       guard, mirroring `build_message`;
     - `applyEditTagOverlay` now overlays `emoji` tags from the edit too (not
       just `imeta`), so both the optimistic cache update AND the receiver
       render path (formatTimelineMessages) reflect the edited custom-emoji set.

Verified: 473 JS unit tests (incl. new emoji-overlay cases), typecheck, biome
lint, vite build, the 4 custom-emoji e2e specs, and `cargo check` + events
tests on the Rust side. No edit-flow e2e (the mock bridge has no edit_message
seam today); the tag-overlay logic is unit-covered and the markdown-parse path
is the kind of thing to confirm by eye in the running app.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…tions

The custom-emoji PR changed behavior on three surfaces the e2e suite never
exercised: editing a message that contains a custom emoji, adding a custom
emoji while editing, and reacting to a system message. All three were
unit-covered or spot-checked only.

Extend the mock bridge so a real interactive flow can drive them:
- handleEditMessage mirrors the real edit_message command (build_message_edit):
  emit a kind:40003 edit event carrying ["e", target] plus the new content,
  imeta tags, and NIP-30 emoji tags. The timeline overlays it via
  applyEditTagOverlay — the same path the relay drives — so recording + emitting
  the edit event is all the bridge needs.
- seed a kind:40099 join event in general (distinct 64-hex id) as a reactable
  system-message target. It renders as system-message-row, not message-row, so
  it leaves every existing message-row index assertion untouched.

Add three specs to custom-emoji.spec.ts:
- Bug 1: editing a message with a custom emoji shows the inline image, not
  the literal :shortcode: (guards the markdown parse-on-load fix).
- Bug 2: adding a custom emoji while editing keeps the image after save
  (guards the edit-path emoji-tag attachment + overlay).
- a system message accepts a custom-emoji reaction.

Test-only; no production code changed. 7 custom-emoji e2e pass; full
mock-bridge e2e set (messaging, identity-archive, mentions, custom-emoji)
green; typecheck + lint + 473 JS unit + file-size guard clean.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
…us spacing, shortcode boundaries

Pinky's adversarial review (corroborated independently by Codex) surfaced two
CHANGE-level issues and one boundary nit on the emoji-picker branch. All three
fixed here.

CHANGE 1 — edited legacy/cross-client messages could lose emoji resolution.
applyEditTagOverlay unconditionally dropped the original's NIP-30 emoji tags
whenever any kind:40003 edit existed, then re-added only the edit's. An edit
from an older build (before edits carried emoji tags) or a client that doesn't
know the emoji_tags path has only h/e tags — so an unrelated text edit would
strip the only shortcode->url mapping and re-break a :catjam: that the original
rendered fine. Now: emoji tags are replaced only when the edit actually
supplies some; a tag-less edit PRESERVES the original's emoji tags. This is
strictly safe — an orphaned emoji tag whose shortcode is no longer in the body
resolves nothing, so preserve-on-empty can't cause a stale render. imeta tags
keep their always-replace semantics (the composer re-emits the full set).
Updated the unit test that encoded the buggy "strip on empty" assumption and
added two cases pinning the new asymmetry.

CHANGE 2 — native status emoji lost spacing. StatusEmoji's native-glyph/unknown
fallback returned <span>{value}</span>, dropping the caller's className; every
display site passes spacing classes (mr-1 h-3.5 w-3.5), so native statuses sat
flush against the status text while image statuses had spacing. Thread
className through the fallback span (the image branch already does).

NIT — the edit-composer markdown-it rule matched mid-word/inside URLs:
not:sprout: and http://x:y:sprout: both materialized an inner image. Added a
word-boundary guard (bail when the colon is glued to a preceding [0-9A-Za-z_]),
plus unit cases driving the actual registered rule for boundary, glued-word,
URL, and punctuation-boundary positions.

479 JS unit pass; typecheck + lint clean; custom-emoji + messaging +
identity-archive + mentions e2e green.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman requested a review from a team as a code owner June 3, 2026 22:03
@wesbillman wesbillman merged commit d8b602a into main Jun 3, 2026
15 checks passed
@wesbillman wesbillman deleted the brain/robust-emoji-picker branch June 3, 2026 22:13
michaelneale added a commit that referenced this pull request Jun 4, 2026
* origin/main: (36 commits)
  fix: use immutable commit-SHA URLs in screenshot PR comments (#842)
  feat(mobile+desktop): two-tier Slack-style app icon badge (#802)
  chore: simplify file-size check to a flat 1000-line limit (#839)
  fix(desktop): robust emoji picker — unify picker + fix custom emoji in editing, status, reactions (#837)
  feat(desktop): reusable screenshot workflow for agents (#826)
  desktop(mesh-llm): let a serving node route a different model (#833)
  chore(release): release version 0.3.9 (#832)
  fix: native arbitrary-file download + image context-menu flash (#830)
  fix(desktop): custom emoji reaction rendering + picker autofocus (#831)
  Mesh-LLM v1: relay-gated direct-iroh inference between users (WAN) (#822)
  chore(release): release version 0.3.8 (#829)
  chore(release): release version 0.3.7 (#825)
  feat: code block rendering, syntax highlighting, and compose fixes (#803)
  feat: custom emoji — user-owned NIP-30 sets with a client-side union (#816)
  Install sprout-cli skill at repo root + fix desktop clippy (#818)
  fix(desktop): use public re-export path for ensure_client_node_for_model (#824)
  refactor(desktop): feature-gate mesh-llm-sdk behind optional Cargo feature (#823)
  fix(desktop): align workflow read/save commands to the frontend contract (#820)
  fix(desktop): disable mesh-llm auto-build to prevent git config corruption (#819)
  fix(desktop): clear clippy lints in agents/mesh_llm commands (#817)
  ...

# Conflicts:
#	Cargo.lock
#	desktop/scripts/check-file-sizes.mjs
#	desktop/src-tauri/Cargo.toml
#	desktop/src/app/AppShell.tsx
#	desktop/src/app/AppTopChrome.tsx
#	desktop/src/features/messages/hooks.ts
#	desktop/src/features/workspaces/useWorkspaceInit.ts
#	desktop/src/shared/api/tauri.ts
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant