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

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-06-03 15:13:25 -07:00
committed by GitHub
co-authored by Brain
parent 06bc67fd34
commit d8b602a359
24 changed files with 696 additions and 107 deletions
+4 -4
View File
@@ -42,9 +42,9 @@ const overrides = new Map([
["src/features/channels/useUnreadChannels.ts", 717], // NIP-RS read marker tracking + participated/authored/followed thread ID sets + localStorage persistence + catch-up REQ with thread activity collection + thread reply activity feed items + mutedRootIds denylist with localStorage persistence + muteThread/unmuteThread callbacks + markChannelRead latestByChannelRef fallback chain (matches markChannelUnread)
["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state
["src/features/home/ui/HomeView.tsx", 505], // inbox/feed orchestration + thread context + reply/delete flow + NIP-RS read-state projection wiring (useHomeInboxReadState)
["src/features/messages/hooks.ts", 525], // message query/mutation hooks + optimistic updates + custom-emoji reaction URL resolution (reactionEmojiUrl from cached relay set) + outgoing tag split (splitOutgoingTags) routing custom-emoji tags through the dedicated emoji_tags send arg so they skip the imeta-only gate
["src/features/messages/hooks.ts", 530], // message query/mutation hooks + optimistic updates + custom-emoji reaction URL resolution (reactionEmojiUrl from cached relay set) + outgoing tag split (splitOutgoingTags) routing custom-emoji tags through the dedicated emoji_tags send arg so they skip the imeta-only gate; edit mutation splits the merged tag set the same way so edited custom emoji ride the dedicated emoji_tags arg
["src/features/messages/lib/useRichTextEditor.ts", 620], // editor setup + 3 inline Tiptap keymap extensions (macEmacs/smartShiftEnter/submitOnEnter) + editorProps.handleKeyDown for ↑-to-edit + editable-toggle focus-restore (records isFocused before disable on send, re-focuses on re-enable so the WebView blur-on-disable doesn't strand focus on body) + custom-emoji atom-node wiring (useComposerCustomEmoji extension + src re-resolve effect; replacePlainTextRange inserts the selectable/copyable CustomEmojiNode for `:shortcode:` so it behaves like a built-in emoji glyph). Split candidate: extract the 3 keymap extensions to a sibling module (tracked follow-up).
["src/features/messages/ui/MessageComposer.tsx", 860], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them + custom emoji: useCustomEmoji feeds picker/autocomplete and mergeOutgoingTags attaches NIP-30 emoji tags on send; insertEmoji inserts a known `:shortcode:` as the selectable CustomEmojiNode atom (else plain native unicode)
["src/features/messages/ui/MessageComposer.tsx", 870], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) + composer autofocus (#572) + Sprout code-block paste branch (round-trips copy-button output as a literal codeBlock so Markdown can't reshape it) + scroll-to-bottom on multi-line paste (#619) + Slack-style attachment-editable edits: seed pendingImeta from edit target, stash/restore user's draft pendingImeta across edit-mode entry/exit, re-append imeta markdown lines on edit-submit so renderer draws them + custom emoji: useCustomEmoji feeds picker/autocomplete and mergeOutgoingTags attaches NIP-30 emoji tags on send AND on edit-save (so edited custom emoji stay resolvable); insertEmoji inserts a known `:shortcode:` as the selectable CustomEmojiNode atom (else plain native unicode)
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 830], // channels + forums creation forms + Pulse nav + channel sections state/dialogs + SidebarDndContext wrapper + sectionIds memo for DnD section reorder + controlled create-channel dialog for ⌘⇧N shortcut
["src/features/sidebar/ui/CustomChannelSection.tsx", 615], // ChannelGroupSection + CustomChannelSection + SectionHeaderActions + ChannelContextMenuItems + MoveToSectionSubmenu + per-section mark-all-read + DnD wrappers (SortableSectionShell, DraggableChannelRow, DroppableSectionBody, DroppableUngroupedBody) + draggable prop on ChannelGroupSection
@@ -52,7 +52,7 @@ const overrides = new Map([
["src-tauri/src/migration.rs", 1010], // worktree shared-agent-data symlink sync (SHARED_AGENT_FILES + SHARED_AGENT_DIRS symlink-to-canonical + sibling pack migration) + mcp_command provider reconciliation + persona_pack_path reconciliation + tests
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 910], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload + relay-mesh client preflight (start_local_agent_with_preflight + StartTarget split: async ensure outside store lock, then sync spawn under lock)
["src-tauri/src/commands/messages.rs", 525], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + edit_message media_tags param (Slack-style attachment-editable edits) + add_reaction custom-emoji branch (build_custom_emoji_reaction when emoji_url is set)
["src-tauri/src/commands/messages.rs", 530], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + edit_message media_tags + emoji_tags params (Slack-style attachment edits + edited custom emoji stay resolvable) + add_reaction custom-emoji branch (build_custom_emoji_reaction when emoji_url is set)
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 1390], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) + system-wide orphan sweep (proc_listallpids/proc on macOS, /proc on Linux) + SPROUT_MANAGED_AGENT env marker check (KERN_PROCARGS2 on macOS, /proc/environ on Linux), instance-scoped by app identifier so coexisting Sprout instances don't reap each other's agents + relay_mesh_model_id detector (mesh preset-env match) + tests
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
@@ -71,7 +71,7 @@ const overrides = new Map([
["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src-tauri/src/events.rs", 825], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test + emoji_tags() NIP-30 builder (mirrors the imeta_tags injection guard, first elem must be "emoji")
["src-tauri/src/events.rs", 830], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders + NIP-IA identity-archive builders (9035/9036) + .allow_self_tagging() guards (nostr 0.44 strips self-`p` by default; self-archive/unarchive needs it preserved) + spec vector 1 layout test + emoji_tags() NIP-30 builder (mirrors the imeta_tags injection guard, first elem must be "emoji"); build_message_edit also threads emoji_tags so edited custom emoji stay resolvable
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block
["src-tauri/src/huddle/stt.rs", 580], // STT pipeline + PTT edge-detection flush + PTT gating (is_speech AND ptt_active) + barge-in for VAD mode + rubato resampler + earshot VAD + sherpa-onnx transcription
+4 -1
View File
@@ -402,6 +402,7 @@ pub async fn edit_message(
event_id: String,
content: String,
media_tags: Vec<Vec<String>>,
emoji_tags: Option<Vec<Vec<String>>>,
state: State<'_, AppState>,
) -> Result<(), String> {
let channel_uuid = uuid::Uuid::parse_str(&channel_id)
@@ -413,7 +414,9 @@ pub async fn edit_message(
if trimmed.is_empty() && media_tags.is_empty() {
return Err("edit must have content or attachments".into());
}
let builder = events::build_message_edit(channel_uuid, target_eid, trimmed, &media_tags)?;
let emoji = emoji_tags.unwrap_or_default();
let builder =
events::build_message_edit(channel_uuid, target_eid, trimmed, &media_tags, &emoji)?;
submit_event(builder, &state).await?;
Ok(())
}
+5 -1
View File
@@ -302,12 +302,15 @@ pub fn build_forum_comment(
/// Kind 40003 — edit a message. Carries the full new content AND a fresh
/// imeta tag set; the receiver overlays the imeta tags onto the original
/// event so the rendered message reflects exactly the edited state.
/// event so the rendered message reflects exactly the edited state. NIP-30
/// custom-emoji tags ride along the same way so an edited body's `:shortcode:`s
/// stay resolvable (the send path attaches these too).
pub fn build_message_edit(
channel_id: Uuid,
target_event_id: EventId,
content: &str,
media_tags: &[Vec<String>],
custom_emoji_tags: &[Vec<String>],
) -> Result<EventBuilder, String> {
check_content(content)?;
let mut tags = vec![
@@ -315,6 +318,7 @@ pub fn build_message_edit(
tag(vec!["e", &target_event_id.to_hex()])?,
];
imeta_tags(media_tags, &mut tags)?;
emoji_tags(custom_emoji_tags, &mut tags)?;
Ok(EventBuilder::new(Kind::Custom(40003), content).tags(tags))
}
@@ -0,0 +1,67 @@
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import * as React from "react";
import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
/**
* The one emoji picker for the whole app. Every place that lets a user choose
* an emoji composing a message, reacting to a regular or system message,
* setting a status renders this, so the config and custom-emoji wiring can't
* drift across call sites (they used to, and that's why custom emoji were
* missing from some pickers).
*
* It always wires the workspace custom-emoji palette in via `useCustomEmoji()`,
* so custom emoji show up everywhere for free. Selection is normalized to a
* single string: a standard emoji emits its `native` glyph; a custom emoji has
* no `native`, so it emits its `:shortcode:` (the emoji-mart `id` is the
* shortcode). Consumers store/send that string and let the existing renderers
* (reactions' `emojiUrl`, the remark shortcode plugin) resolve it to an image.
*
* Only the raw picker lives here not the Popover/trigger. Those differ per
* site (ghost button vs status swatch, popover vs dialog content) and forcing
* them into one wrapper would be less clear, not more. The thing that drifted
* was the picker config + custom wiring + select handling; that's what this
* centralizes.
*/
type EmojiPickerProps = {
/** Autofocus the search field when the picker mounts (e.g. reaction popovers). */
autoFocus?: boolean;
/** Called with the chosen emoji as a string: `native` glyph or `:shortcode:`. */
onSelect: (emoji: string) => void;
};
export const EmojiPicker = React.memo(function EmojiPicker({
autoFocus = false,
onSelect,
}: EmojiPickerProps) {
const customEmoji = useCustomEmoji();
const custom = React.useMemo(
() => buildCustomEmojiCategory(customEmoji),
[customEmoji],
);
return (
<Picker
autoFocus={autoFocus}
custom={custom}
data={data}
maxFrequentRows={2}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
// Standard emoji carry a `native` glyph. Custom emoji don't — emit
// their `:shortcode:` (emoji-mart `id` == shortcode) instead. Ignore a
// malformed selection that has neither.
const value = emoji.native ?? (emoji.id ? `:${emoji.id}:` : "");
if (value) {
onSelect(value);
}
}}
perLine={8}
previewPosition="none"
set="native"
skinTonePosition="search"
theme="auto"
/>
);
});
+7 -1
View File
@@ -491,7 +491,13 @@ export function useEditMessageMutation(channel: Channel | null) {
throw new Error("No channel selected.");
}
await editMessage(channel.id, eventId, content, mediaTags);
// `mediaTags` arrives as the merged outgoing set (imeta + NIP-30 emoji).
// Split so each rides its own validated Tauri arg — emoji tags must NOT
// go through the imeta-only `mediaTags` channel (the Rust `imeta_tags`
// guard rejects any non-imeta prefix), mirroring the send path.
const { mediaTags: imetaTags, emojiTags } = splitOutgoingTags(mediaTags);
await editMessage(channel.id, eventId, content, imetaTags, emojiTags);
},
onSuccess: (_data, { eventId, content, mediaTags }) => {
if (!channel) {
@@ -7,9 +7,9 @@
export type Tag = string[];
/**
* Merge an event's tags with an edit's tags: imeta from the edit (full new
* attachment set), all other tag kinds from the original. Pass-through when
* `editTags` is `undefined`.
* Merge an event's tags with an edit's tags: imeta + NIP-30 emoji tags from the
* edit (full new attachment + custom-emoji set), all other tag kinds from the
* original. Pass-through when `editTags` is `undefined`.
*/
export function applyEditTagOverlay(
originalTags: Tag[],
@@ -12,6 +12,16 @@
/**
* Merge the original event's tags with an edit's tags so that:
* - `imeta` tags come exclusively from the edit (full new attachment set);
* - `emoji` (NIP-30 custom-emoji) tags come from the edit *when the edit
* supplies any* the edited body may add or remove custom emoji, so a
* supplied set rebuilds the shortcodeurl map. But when the edit supplies
* NO emoji tags, the original's emoji tags are PRESERVED. A tag-less edit
* can come from an older build (before edits carried emoji tags) or another
* client that doesn't know this path; dropping the original's emoji tags
* there would strip the only shortcodeurl mapping and re-break a
* `:shortcode:` that the original rendered fine. Preserving on empty is
* strictly safe: an orphaned emoji tag whose shortcode is no longer in the
* body resolves nothing, so it can't cause a stale render.
* - all other tag kinds (`h`, `e`, `p` mentions, etc.) come exclusively
* from the original the edit can't rewrite channel membership,
* thread refs, or mention targets.
@@ -20,7 +30,14 @@
*/
export function applyEditTagOverlay(originalTags, editTags) {
if (!editTags) return originalTags;
const nonImetaOriginal = originalTags.filter((t) => t[0] !== "imeta");
const imetaFromEdit = editTags.filter((t) => t[0] === "imeta");
return [...nonImetaOriginal, ...imetaFromEdit];
const editEmoji = editTags.filter((t) => t[0] === "emoji");
// imeta is always fully replaced by the edit. emoji is replaced only when
// the edit actually supplies emoji tags; otherwise the original's are kept.
const droppedFromOriginal =
editEmoji.length > 0
? (t) => t[0] !== "imeta" && t[0] !== "emoji"
: (t) => t[0] !== "imeta";
const baseFromOriginal = originalTags.filter(droppedFromOriginal);
const overlaidFromEdit = editTags.filter((t) => t[0] === "imeta");
return [...baseFromOriginal, ...overlaidFromEdit, ...editEmoji];
}
@@ -103,3 +103,101 @@ test("edit's non-imeta tags are dropped (only imeta wins)", () => {
// Imeta from the edit is present.
assert.equal(out.filter((t) => t[0] === "imeta").length, 1);
});
const EMOJI = (shortcode, url) => ["emoji", shortcode, url];
test("edit replaces the original's emoji tags with the edit's set", () => {
// Original had :catjam:; edit adds :rickroll: and keeps :catjam: — the
// merged emoji set must come entirely from the edit (add/remove honored).
const original = [
["h", "uuid"],
["p", "mention1"],
EMOJI("catjam", "https://b/catjam.gif"),
];
const edit = [
["h", "uuid"],
["e", "x"],
EMOJI("catjam", "https://b/catjam.gif"),
EMOJI("rickroll", "https://b/rickroll.gif"),
];
const out = applyEditTagOverlay(original, edit);
// Emoji tags now match the edit's set (catjam + rickroll).
const emoji = out.filter((t) => t[0] === "emoji").map((t) => t[1]);
assert.deepEqual(emoji, ["catjam", "rickroll"]);
// Original mention preserved.
assert.ok(out.some((t) => t[0] === "p" && t[1] === "mention1"));
});
test("a tag-less edit (legacy/cross-client) PRESERVES the original's emoji tags", () => {
// The bug this guards: an edit event that carries no emoji tags — from an
// older build or a client that doesn't know the emoji_tags path — must NOT
// strip the original's emoji resolution. Otherwise an unrelated text edit
// would re-break a `:catjam:` the original rendered fine.
const original = [
["h", "uuid"],
["p", "mention1"],
EMOJI("catjam", "https://b/catjam.gif"),
];
const edit = [
["h", "uuid"],
["e", "x"],
];
const out = applyEditTagOverlay(original, edit);
// The original's emoji tag survives intact.
assert.deepEqual(
out.filter((t) => t[0] === "emoji"),
[EMOJI("catjam", "https://b/catjam.gif")],
);
// Other original tags survive too.
assert.ok(out.some((t) => t[0] === "h"));
assert.ok(out.some((t) => t[0] === "p" && t[1] === "mention1"));
});
test("a tag-less edit still fully replaces imeta (attachments), unlike emoji", () => {
// imeta is always rebuilt from the edit (the composer re-emits the full
// attachment set), so a tag-less edit removes attachments — but it must NOT
// remove emoji. This pins the asymmetry between the two tag kinds.
const original = [
["h", "uuid"],
IMETA("https://b/a.png"),
EMOJI("catjam", "https://b/catjam.gif"),
];
const edit = [
["h", "uuid"],
["e", "x"],
];
const out = applyEditTagOverlay(original, edit);
// imeta gone (replaced by the edit's empty set).
assert.equal(out.filter((t) => t[0] === "imeta").length, 0);
// emoji preserved (edit supplied none → keep original).
assert.equal(out.filter((t) => t[0] === "emoji").length, 1);
});
test("imeta and emoji are overlaid together from the edit", () => {
const original = [
["h", "uuid"],
IMETA("https://b/a.png"),
EMOJI("catjam", "https://b/catjam.gif"),
];
const edit = [
["h", "uuid"],
["e", "x"],
IMETA("https://b/c.png"),
EMOJI("rickroll", "https://b/rickroll.gif"),
];
const out = applyEditTagOverlay(original, edit);
assert.deepEqual(
out.filter((t) => t[0] === "imeta").map((t) => t[1]),
["url https://b/c.png"],
);
assert.deepEqual(
out.filter((t) => t[0] === "emoji").map((t) => t[1]),
["rickroll"],
);
});
@@ -62,3 +62,77 @@ test("dedupes and ignores blank entries", () => {
const alt = buildKnownShortcodeAlternation(["wave", "wave", "", " "]);
assert.equal(alt, "wave");
});
// ── markdown-it inline rule: word-boundary behavior ──────────────────────
// These drive the *actual* rule registered by registerCustomEmojiMarkdownIt
// (no reimplementation), using a minimal fake markdown-it that captures the
// rule and a minimal `state` shaped like markdown-it's inline state. The guard
// added for the edit-composer parse path must not fire mid-word or inside URLs.
import { registerCustomEmojiMarkdownIt } from "./customEmojiNode.ts";
function captureRule(shortcodes) {
let captured = null;
const md = {
renderer: { rules: {} },
inline: {
ruler: {
before(_anchor, _name, fn) {
captured = fn;
},
},
},
utils: { escapeHtml: (s) => s },
};
registerCustomEmojiMarkdownIt(md, {
shortcodes: () => shortcodes,
resolveUrl: (sc) => `https://b/${sc}.png`,
});
return captured;
}
// Run the rule at `pos`; return whether it matched and how far it advanced.
function runRule(rule, src, pos) {
const state = {
src,
pos,
push: () => ({ meta: {} }),
};
const matched = rule(state, false);
return { matched, advanced: state.pos - pos };
}
test("rule fires for a boundary :shortcode: (start of string)", () => {
const rule = captureRule(["sprout"]);
const { matched, advanced } = runRule(rule, ":sprout:", 0);
assert.equal(matched, true);
assert.equal(advanced, ":sprout:".length);
});
test("rule fires for a :shortcode: preceded by whitespace", () => {
const rule = captureRule(["sprout"]);
// pos points at the `:` after the space.
const { matched } = runRule(rule, "hi :sprout:", 3);
assert.equal(matched, true);
});
test("rule does NOT fire when the colon is glued to a word char (not:sprout:)", () => {
const rule = captureRule(["sprout"]);
// pos points at the `:` immediately after `not`.
const { matched } = runRule(rule, "not:sprout:", 3);
assert.equal(matched, false);
});
test("rule does NOT fire inside a URL-like sequence (http://x:y:sprout:)", () => {
const rule = captureRule(["sprout"]);
const src = "http://x:y:sprout:";
// pos points at the `:` immediately after `y` (a word char).
const { matched } = runRule(rule, src, src.indexOf(":sprout:"));
assert.equal(matched, false);
});
test("rule fires after punctuation boundary (e.g. parenthesis)", () => {
const rule = captureRule(["sprout"]);
// `(` is not a word char, so a `:shortcode:` after it still materializes.
const { matched } = runRule(rule, "(:sprout:)", 1);
assert.equal(matched, true);
});
@@ -47,6 +47,92 @@ export function buildKnownShortcodeAlternation(
return sorted.map((s) => escapeRegExp(s)).join("|");
}
/**
* Register a markdown-it inline rule that turns a known `:shortcode:` into an
* `<img data-custom-emoji ...>` (the same shape `renderHTML` emits). Used by the
* node's markdown `parse.setup` so loading content via `setContent` editing
* an existing message materializes custom-emoji nodes instead of leaving raw
* `:shortcode:` text. Unknown shortcodes are left untouched (rendered as the
* literal text they are).
*
* The rule and renderer are self-contained: matching uses the *current* known
* set (read lazily on each parse), and the token carries the resolved url so
* the renderer needs no further lookup.
*/
export function registerCustomEmojiMarkdownIt(
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
md: any,
options: CustomEmojiNodeOptions,
): void {
const RULE_NAME = "sprout_custom_emoji";
const TOKEN_TYPE = "sprout_custom_emoji";
// `parse.setup` runs on every parse against the *same* markdown-it instance,
// so only register the rule + renderer once — `ruler.before` throws on a
// duplicate rule name.
if (md.renderer.rules[TOKEN_TYPE]) return;
// biome-ignore lint/suspicious/noExplicitAny: markdown-it state/silent
const rule = (state: any, silent: boolean): boolean => {
// Fast bail: a shortcode must start with `:`.
if (state.src.charCodeAt(state.pos) !== 0x3a /* : */) return false;
// Word-boundary guard: don't fire when the `:` is glued to a preceding
// word char. This keeps prose and URLs intact — `not:sprout:` and
// `http://x:y:sprout:` must NOT turn the inner `:sprout:` into an image;
// only a `:shortcode:` at a boundary (start of line, after whitespace or
// punctuation) materializes. Slack-style boundary semantics.
if (state.pos > 0) {
const prev = state.src.charCodeAt(state.pos - 1);
const isWordChar =
(prev >= 0x30 && prev <= 0x39) /* 0-9 */ ||
(prev >= 0x41 && prev <= 0x5a) /* A-Z */ ||
(prev >= 0x61 && prev <= 0x7a) /* a-z */ ||
prev === 0x5f; /* _ */
if (isWordChar) return false;
}
const alternation = buildKnownShortcodeAlternation(options.shortcodes());
if (!alternation) return false;
// Match a known `:shortcode:` anchored at the current position. `y` (sticky)
// anchors at lastIndex without `^`, so we don't accidentally match later in
// the line.
const re = new RegExp(`:(?:${alternation}):`, "iy");
re.lastIndex = state.pos;
const match = re.exec(state.src);
if (!match) return false;
if (!silent) {
const matched = match[0];
const shortcode = matched.slice(1, -1).toLowerCase();
const token = state.push(TOKEN_TYPE, "img", 0);
token.meta = { shortcode, src: options.resolveUrl(shortcode) ?? "" };
}
state.pos += match[0].length;
return true;
};
// Run before markdown-it's own `:` handling (emphasis etc. are unaffected;
// there is no built-in inline rule named "text" collision here). Inserting
// before "emphasis" is safe and early enough.
md.inline.ruler.before("emphasis", RULE_NAME, rule);
// biome-ignore lint/suspicious/noExplicitAny: markdown-it token
md.renderer.rules[TOKEN_TYPE] = (tokens: any[], idx: number): string => {
const { shortcode, src } = tokens[idx].meta as {
shortcode: string;
src: string;
};
const esc = md.utils.escapeHtml;
// Mirror renderHTML(): the resolved url is rewritten through the media
// proxy at PM-render time, so here we emit the raw `src`; `parseHTML`
// re-derives the node from `data-shortcode` and the palette supplies the
// live url. We still set `src` so a fully-formed <img> round-trips cleanly.
return `<img data-custom-emoji data-shortcode="${esc(shortcode)}" src="${esc(src)}" alt=":${esc(shortcode)}:" />`;
};
}
export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
name: CUSTOM_EMOJI_NODE_NAME,
@@ -132,7 +218,22 @@ export const CustomEmojiNode = Node.create<CustomEmojiNodeOptions>({
) {
state.write(`:${node.attrs.shortcode}:`);
},
parse: {},
// Parse loaded markdown (e.g. editing an existing message via
// `setContent`) so a known `:shortcode:` becomes the atom node instead
// of staying plain text. Input rules only fire on live keystrokes, so
// without this an edited message shows the raw `:shortcode:`.
//
// We add a markdown-it inline rule that emits an `<img data-custom-emoji>`
// — the same shape `renderHTML` produces — which the node's `parseHTML`
// (`img[data-custom-emoji]`) then materializes. Using our own token +
// renderer sidesteps the `html: false` gate (that only blocks raw HTML
// the *user* typed, not tokens we synthesize).
parse: {
// biome-ignore lint/suspicious/noExplicitAny: markdown-it is untyped here
setup(this: { options: CustomEmojiNodeOptions }, md: any) {
registerCustomEmojiMarkdownIt(md, this.options);
},
},
},
};
},
@@ -1,16 +1,12 @@
import * as React from "react";
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import { SmilePlus } from "lucide-react";
import * as React from "react";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { Button } from "@/shared/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
type ComposerEmojiPickerProps = {
customEmoji?: CustomEmoji[];
disabled?: boolean;
onEmojiSelect: (emoji: string) => void;
onOpenChange: (open: boolean) => void;
@@ -19,17 +15,12 @@ type ComposerEmojiPickerProps = {
};
export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
customEmoji = [],
disabled = false,
onEmojiSelect,
onOpenChange,
onTriggerMouseDown,
open,
}: ComposerEmojiPickerProps) {
const custom = React.useMemo(
() => buildCustomEmojiCategory(customEmoji),
[customEmoji],
);
return (
<Popover onOpenChange={onOpenChange} open={open}>
<Tooltip>
@@ -56,25 +47,7 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({
side="top"
sideOffset={10}
>
<Picker
data={data}
custom={custom}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
// Custom emoji have no `native`; insert their `:shortcode:` (the
// emoji-mart id is the shortcode). Standard emoji insert `native`.
if (emoji.native) {
onEmojiSelect(emoji.native);
} else if (emoji.id) {
onEmojiSelect(`:${emoji.id}:`);
}
}}
theme="auto"
previewPosition="none"
skinTonePosition="search"
set="native"
maxFrequentRows={2}
perLine={8}
/>
<EmojiPicker onSelect={onEmojiSelect} />
</PopoverContent>
</Popover>
);
@@ -1,5 +1,3 @@
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import {
BellOff,
BellRing,
@@ -16,8 +14,7 @@ import * as React from "react";
import { toast } from "sonner";
import { buildMessageLink } from "@/features/messages/lib/messageLink";
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { buildCustomEmojiCategory } from "@/features/custom-emoji/emojiMartCategory";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { getThreadReference } from "@/features/messages/lib/threading";
import type {
TimelineMessage,
@@ -294,11 +291,6 @@ export function MessageActionBar({
}) {
const [isReactionPickerOpen, setIsReactionPickerOpen] = React.useState(false);
const [isDropdownOpen, setIsDropdownOpen] = React.useState(false);
const customEmoji = useCustomEmoji();
const customEmojiCategory = React.useMemo(
() => buildCustomEmojiCategory(customEmoji),
[customEmoji],
);
const hasReplyAction = Boolean(onReply);
const hasReactionAction = Boolean(onReactionSelect);
@@ -377,31 +369,18 @@ export function MessageActionBar({
</p>
</div>
) : null}
<Picker
<EmojiPicker
autoFocus
data={data}
custom={customEmojiCategory}
onEmojiSelect={(emoji: { native?: string; id?: string }) => {
onSelect={(value) => {
if (!onReactionSelect) {
return;
}
// Custom emoji have no `native`; react with `:shortcode:`
// (id == shortcode). The toggle mutation resolves the URL.
const value =
emoji.native ?? (emoji.id ? `:${emoji.id}:` : "");
if (!value) {
return;
}
// `value` is already a `native` glyph or a `:shortcode:` for
// custom emoji; the toggle mutation resolves the URL.
void onReactionSelect(value).finally(() => {
setIsReactionPickerOpen(false);
});
}}
theme="auto"
previewPosition="none"
skinTonePosition="search"
set="native"
maxFrequentRows={2}
perLine={8}
/>
</PopoverContent>
</Popover>
@@ -456,6 +456,17 @@ export function MessageComposer({
currentPendingImeta,
);
// NIP-30: attach `["emoji", shortcode, url]` tags for custom emoji in the
// edited body, exactly like the send path. Without this an edited message
// ships with no emoji tags, so the receiver can't resolve a `:shortcode:`
// and renders the literal text. `?? []` preserves edit semantics (a
// defined-but-empty media set means "wipe attachments").
const outgoingTags =
mergeOutgoingTags(
mediaTags,
buildCustomEmojiTags(finalContent, customEmoji),
) ?? [];
const savedContent = trimmed;
const savedImeta = [...currentPendingImeta];
setContent("");
@@ -468,7 +479,7 @@ export function MessageComposer({
setIsEmojiPickerOpen(false);
try {
await onEditSaveRef.current(finalContent, mediaTags ?? []);
await onEditSaveRef.current(finalContent, outgoingTags);
} catch {
setContent(savedContent);
contentRef.current = savedContent;
@@ -833,7 +844,6 @@ export function MessageComposer({
<MessageComposerToolbar
composerDisabled={disabled}
customEmoji={customEmoji}
editor={richText.editor}
extraActions={toolbarExtraActions}
formattingDisabled={disabled}
@@ -4,7 +4,6 @@ import { AnimatePresence, motion } from "motion/react";
import { ALargeSmall, ArrowUp, AtSign, Paperclip, X } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
import { Button } from "@/shared/ui/button";
import { Spinner } from "@/shared/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -21,7 +20,6 @@ const presenceSpring = {
export const MessageComposerToolbar = React.memo(
function MessageComposerToolbar({
composerDisabled,
customEmoji,
editor,
extraActions,
formattingDisabled,
@@ -38,7 +36,6 @@ export const MessageComposerToolbar = React.memo(
sendDisabled,
}: {
composerDisabled: boolean;
customEmoji?: CustomEmoji[];
editor: Editor | null;
extraActions?: React.ReactNode;
formattingDisabled: boolean;
@@ -200,7 +197,6 @@ export const MessageComposerToolbar = React.memo(
<TooltipContent>Attach image</TooltipContent>
</Tooltip>
<ComposerEmojiPicker
customEmoji={customEmoji}
disabled={composerDisabled}
onEmojiSelect={onEmojiSelect}
onOpenChange={onEmojiPickerOpenChange}
@@ -1,8 +1,7 @@
import { SmilePlus } from "lucide-react";
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import * as React from "react";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import type { TimelineMessage } from "@/features/messages/types";
import { MessageReactions } from "@/features/messages/ui/MessageReactions";
import { useReactionHandler } from "@/features/messages/ui/useReactionHandler";
@@ -415,19 +414,12 @@ export const SystemMessageRow = React.memo(function SystemMessageRow({
</p>
</div>
) : null}
<Picker
data={data}
onEmojiSelect={(emoji: { native: string }) => {
void handleReactionSelect(emoji.native).finally(() => {
<EmojiPicker
onSelect={(value) => {
void handleReactionSelect(value).finally(() => {
setIsReactionPickerOpen(false);
});
}}
theme="auto"
previewPosition="none"
skinTonePosition="search"
set="native"
maxFrequentRows={2}
perLine={8}
/>
</PopoverContent>
</Popover>
@@ -6,6 +6,7 @@ import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { getPresenceLabel } from "@/features/presence/lib/presence";
import { SetStatusDialog } from "@/features/user-status/ui/SetStatusDialog";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import type { PresenceStatus } from "@/shared/api/types";
// ---------------------------------------------------------------------------
@@ -152,7 +153,10 @@ export function ProfilePopover({
data-testid="profile-popover-user-status"
>
{userStatusEmoji ? (
<span className="mr-1">{userStatusEmoji}</span>
<StatusEmoji
className="mr-1 h-3.5 w-3.5"
value={userStatusEmoji}
/>
) : null}
{userStatusText}
</p>
@@ -28,6 +28,7 @@ import {
import { usePresenceQuery } from "@/features/presence/hooks";
import { useMyRelayMembershipQuery } from "@/features/relay-members/hooks";
import { useUserStatusQuery } from "@/features/user-status/hooks";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { BotIdenticon } from "@/features/messages/ui/BotIdenticon";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
@@ -330,7 +331,10 @@ export function UserProfilePanel({
{userStatus ? (
<p className="text-center text-sm text-muted-foreground">
{userStatus.emoji ? (
<span className="mr-1">{userStatus.emoji}</span>
<StatusEmoji
className="mr-1 h-3.5 w-3.5"
value={userStatus.emoji}
/>
) : null}
{userStatus.text}
</p>
@@ -8,6 +8,7 @@ import {
} from "@/features/agents/hooks";
import { usePresenceQuery } from "@/features/presence/hooks";
import { useUserStatusQuery } from "@/features/user-status/hooks";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
import { useAgentSession } from "@/shared/context/AgentSessionContext";
@@ -217,7 +218,10 @@ export function UserProfilePopover({
data-testid="user-profile-status"
>
{userStatus.emoji ? (
<span className="mr-1">{userStatus.emoji}</span>
<StatusEmoji
className="mr-1 h-3.5 w-3.5"
value={userStatus.emoji}
/>
) : null}
{userStatus.text}
</p>
@@ -21,6 +21,7 @@ import { getPresenceLabel } from "@/features/presence/lib/presence";
import { PresenceDot } from "@/features/presence/ui/PresenceBadge";
import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar";
import { ProfilePopover } from "@/features/profile/ui/ProfilePopover";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import {
useChannelSections,
type ChannelSection,
@@ -730,7 +731,10 @@ export function AppSidebar({
{selfUserStatus?.text || selfUserStatus?.emoji ? (
<p className="mt-0.5 truncate text-xs text-sidebar-foreground/50">
{selfUserStatus.emoji ? (
<span className="mr-1">{selfUserStatus.emoji}</span>
<StatusEmoji
className="mr-1 h-3.5 w-3.5"
value={selfUserStatus.emoji}
/>
) : null}
{selfUserStatus.text}
</p>
@@ -1,8 +1,8 @@
import * as React from "react";
import Picker from "@emoji-mart/react";
import data from "@emoji-mart/data";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
import {
Dialog,
DialogContent,
@@ -65,8 +65,8 @@ export function SetStatusDialog({
setEmoji(preset.emoji);
}
function handleEmojiSelect(selectedEmoji: { native: string }) {
setEmoji(selectedEmoji.native);
function handleEmojiSelect(selectedEmoji: string) {
setEmoji(selectedEmoji);
setPickerOpen(false);
}
@@ -110,7 +110,11 @@ export function SetStatusDialog({
className="flex h-9 w-9 items-center justify-center rounded-md border border-input text-lg transition-colors hover:bg-accent"
type="button"
>
{emoji || "\uD83D\uDCAC"}
{emoji ? (
<StatusEmoji className="h-5 w-5" value={emoji} />
) : (
"\uD83D\uDCAC"
)}
</button>
</PopoverTrigger>
{emoji ? (
@@ -132,16 +136,7 @@ export function SetStatusDialog({
sideOffset={4}
className="z-50 w-auto overflow-hidden rounded-2xl shadow-md outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
>
<Picker
data={data}
maxFrequentRows={2}
onEmojiSelect={handleEmojiSelect}
perLine={8}
previewPosition="none"
set="native"
skinTonePosition="search"
theme="auto"
/>
<EmojiPicker autoFocus onSelect={handleEmojiSelect} />
</PopoverPrimitive.Content>
</Popover>
<Input
@@ -0,0 +1,57 @@
import { useCustomEmoji } from "@/features/custom-emoji/hooks";
import { cn } from "@/shared/lib/cn";
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
/**
* Render a user-status emoji from its stored string. A status emoji is a bare
* string (unlike reactions, which carry a companion `emojiUrl`): a native glyph
* like `💬`, or a custom-emoji `:shortcode:`. This resolves a known shortcode to
* its workspace image and renders it as an `<img>`; anything else (native glyph,
* or an unknown `:foo:`) renders as text.
*
* Every place that shows a status emoji renders this, so the shortcodeimage
* resolution can't drift across the (five) display sites the same reason the
* picker is unified. The relay URL is rewritten through the localhost media
* proxy, matching reactions' `EmojiGlyph` (WKWebView bypasses WARP, so a direct
* relay URL 403s and renders broken).
*/
type StatusEmojiProps = {
/** The stored status emoji: a native glyph or a custom `:shortcode:`. */
value: string | undefined;
/** Sizes the resolved custom image to match the surrounding text. */
className?: string;
};
const SHORTCODE_RE = /^:([^:\s]+):$/;
export function StatusEmoji({ value, className }: StatusEmojiProps) {
const customEmoji = useCustomEmoji();
if (!value) return null;
const match = value.match(SHORTCODE_RE);
if (match) {
const shortcode = match[1].toLowerCase();
const found = customEmoji.find(
(e) => e.shortcode.toLowerCase() === shortcode,
);
if (found) {
return (
<img
alt={value}
src={rewriteRelayUrl(found.url)}
className={cn(
"inline-block object-contain align-text-bottom",
className,
)}
draggable={false}
/>
);
}
}
// Native glyph, or an unknown shortcode we can't resolve — render as text.
// Thread the caller's className through so native statuses keep the spacing
// (e.g. `mr-1`) every display site applies to the image branch above.
return <span className={className}>{value}</span>;
}
+2
View File
@@ -791,12 +791,14 @@ export async function editMessage(
eventId: string,
content: string,
mediaTags?: string[][],
emojiTags?: string[][],
): Promise<void> {
await invokeTauri("edit_message", {
channelId,
eventId,
content,
mediaTags: mediaTags ?? [],
emojiTags: emojiTags ?? [],
});
}
+77
View File
@@ -8,6 +8,10 @@ import {
CUSTOM_EMOJI_SET_D_TAG,
KIND_EMOJI_SET,
} from "@/shared/api/customEmoji";
import {
KIND_STREAM_MESSAGE_EDIT,
KIND_SYSTEM_MESSAGE,
} from "@/shared/constants/kinds";
import type {
RawAcpProviderCatalogEntry,
RawInstallRuntimeResult,
@@ -545,6 +549,10 @@ const REACTION_EMOJI_URL = `${DEFAULT_RELAY_HTTP_URL}/media/${REACTION_EMOJI_SHA
// test locates its row without relying on seed ordering.
const REACTION_TARGET_EVENT_ID = "d".repeat(64);
const REACTION_TARGET_CONTENT = "React to me with a custom emoji";
// System-message reaction target id (kind:40099 join event). Distinct 64-hex
// id so it is a valid reaction target and never collides with the regular
// REACTION_TARGET_EVENT_ID.
const SYSTEM_REACTION_TARGET_EVENT_ID = "e".repeat(64);
const E2E_IDENTITY_OVERRIDE_STORAGE_KEY = "sprout:e2e-identity-override.v1";
const DEFAULT_MOCK_IDENTITY = {
pubkey: "deadbeef".repeat(8),
@@ -1825,6 +1833,25 @@ function getMockMessageStore(channelId: string): RelayEvent[] {
content: REACTION_TARGET_CONTENT,
sig: "mocksig".repeat(20).slice(0, 128),
},
// System-message reaction target. A kind:40099 join event renders via
// SystemMessageRow (testid `system-message-row`, NOT `message-row`),
// so it never displaces the `message-row` index assertions other
// specs rely on. Real 64-hex id so getReactionTargetId() accepts it
// as a reaction target — this is the surface the original "react to a
// system message" bug lived on. Backdated like the other seeds.
{
id: SYSTEM_REACTION_TARGET_EVENT_ID,
pubkey: ALICE_PUBKEY,
created_at: Math.floor(Date.now() / 1000) - 30,
kind: KIND_SYSTEM_MESSAGE,
tags: [["h", channelId]],
content: JSON.stringify({
type: "member_joined",
actor: ALICE_PUBKEY,
target: ALICE_PUBKEY,
}),
sig: "mocksig".repeat(20).slice(0, 128),
},
]
: channelId === "a27e1ee9-76a6-5bdf-a5d5-1d85610dad11"
? [
@@ -4613,6 +4640,51 @@ async function handleSendChannelMessage(
};
}
/**
* Mock the `edit_message` Tauri command. Mirrors the real Rust command
* (`build_message_edit`): emit a kind:40003 edit event carrying `["e", target]`
* plus the new content, media (imeta) tags, and NIP-30 emoji tags. The timeline
* (`formatTimelineMessages`) scans for these edit events and overlays the new
* content + media/emoji tags onto the original via `applyEditTagOverlay`, so
* recording + emitting the edit event is all the bridge needs to do the same
* path the real relay drives. `null`/empty tag args no extra tags.
*/
async function handleEditMessage(
args: {
channelId: string;
eventId: string;
content: string;
mediaTags?: string[][] | null;
emojiTags?: string[][] | null;
},
config: E2eConfig | undefined,
): Promise<void> {
const mediaTags = args.mediaTags ?? [];
const emojiTags = args.emojiTags ?? [];
const extraTags = [...mediaTags, ...emojiTags];
const tags = [["h", args.channelId], ["e", args.eventId], ...extraTags];
const content = args.content.trim();
const identity = getIdentity(config);
if (!identity) {
const editEvent = createMockEvent(
KIND_STREAM_MESSAGE_EDIT,
content,
tags,
getMockMemberPubkey(config),
);
recordMockMessage(args.channelId, editEvent);
emitMockLiveEvent(args.channelId, editEvent);
return;
}
await submitSignedEvent(config, {
kind: KIND_STREAM_MESSAGE_EDIT,
content,
tags,
});
}
/** Locate the channel a stored mock event lives in (reactions carry no channel arg). */
function findMockEventChannel(eventId: string): string | undefined {
for (const [channelId, events] of mockMessages) {
@@ -5475,6 +5547,11 @@ export function maybeInstallE2eTauriMocks() {
payload as Parameters<typeof handleSendChannelMessage>[0],
activeConfig,
);
case "edit_message":
return handleEditMessage(
payload as Parameters<typeof handleEditMessage>[0],
activeConfig,
);
case "add_reaction":
return handleAddReaction(
payload as Parameters<typeof handleAddReaction>[0],
+122
View File
@@ -164,3 +164,125 @@ test("reacting with a custom emoji renders via the localhost media proxy", async
await row.getByLabel(`Toggle :${REACTION_SHORTCODE}: reaction`).click();
await expect(reactionImg).toHaveCount(0);
});
// Edit-flow regression guards.
//
// Two bugs lived on the edit path and were invisible to the send-only specs
// above:
// Bug 1 — opening a message that contains a custom emoji for editing showed
// the literal `:shortcode:` text in the composer instead of the inline
// image. The node only materialized via the live input rule; loading via
// `setContent` (how edit-open seeds the composer) left it as text because
// the customEmoji node had no markdown parse rule.
// Bug 2 — adding a custom emoji while editing, then saving, shipped a bare
// `:shortcode:` because the edit-save path didn't attach NIP-30 emoji tags
// (the send path does). Without those tags the renderer can't resolve the
// shortcode → literal text in the timeline.
//
// These drive the real interactive edit flow (More actions → Edit message →
// save) so they exercise the `edit_message` Tauri command end to end. The mock
// bridge mirrors the real relay: it emits a kind:40003 edit event carrying the
// emoji tags, and the timeline overlays it via applyEditTagOverlay.
async function openMessageEditor(
page: import("@playwright/test").Page,
rowText: string,
) {
const row = page
.getByTestId("message-row")
.filter({ hasText: rowText })
.last();
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("More actions").click();
await page.getByRole("menuitem", { name: "Edit message" }).click();
// The composer enters edit mode (shows the edit-target banner).
await expect(page.getByTestId("edit-target")).toBeVisible();
}
test("editing a message with a custom emoji shows the image, not the shortcode (Bug 1)", async ({
page,
}) => {
await openGeneral(page);
// Send our own message containing a custom emoji so it is editable.
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially(`edit-bug1 :${SHORTCODE}:`);
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
await page.getByTestId("send-message").click();
await expect(
page
.getByTestId("message-timeline")
.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`)
.last(),
).toBeVisible();
// Open it for editing. The composer loads via setContent — the path the
// markdown parse rule fixes. The known shortcode must render as the inline
// node, NOT as literal `:sprout:` text.
await openMessageEditor(page, "edit-bug1");
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
await expect(input.locator("img[data-custom-emoji]")).toHaveAttribute(
"alt",
`:${SHORTCODE}:`,
);
// The raw shortcode text must NOT linger in the editor.
await expect(input).not.toContainText(`:${SHORTCODE}:`);
});
test("adding a custom emoji while editing keeps the image after save (Bug 2)", async ({
page,
}) => {
await openGeneral(page);
// Send a plain message we'll edit to add an emoji to.
const input = page.getByTestId("message-input");
await input.click();
await input.pressSequentially("edit-bug2 plain");
await page.getByTestId("send-message").click();
const row = page
.getByTestId("message-row")
.filter({ hasText: "edit-bug2" })
.last();
await expect(row).toBeVisible();
// No emoji yet.
await expect(row.locator("img[data-custom-emoji]")).toHaveCount(0);
// Edit it: append a custom emoji, then save.
await openMessageEditor(page, "edit-bug2");
await input.click();
await input.pressSequentially(` :${SHORTCODE}:`);
await expect(input.locator("img[data-custom-emoji]")).toHaveCount(1);
await page.getByTestId("send-message").click();
// After the edit round-trips through edit_message → kind:40003 (with emoji
// tags) → applyEditTagOverlay, the timeline must render the emoji as an
// <img>, not a bare `:sprout:`. The pre-fix edit path shipped no emoji tags,
// so this row would show literal text and fail here.
await expect(
row.locator(`img[data-custom-emoji][alt=":${SHORTCODE}:"]`),
).toBeVisible();
await expect(row).not.toContainText(`:${SHORTCODE}:`);
});
// System-message reaction guard. The original bug in this PR: system messages
// (joins, topic changes, etc.) couldn't take reactions. The seeded kind:40099
// join event renders via SystemMessageRow, which now carries the reaction
// affordance. This drives the real react flow on a system row and asserts the
// pill appears — the surface the fix targeted.
test("a system message accepts a custom-emoji reaction", async ({ page }) => {
await openGeneral(page);
const row = page.getByTestId("system-message-row").first();
await expect(row).toBeVisible();
await row.hover();
await row.getByLabel("Open reactions").click();
const picker = page.locator("em-emoji-picker");
await picker.locator("input[type='search']").fill(REACTION_SHORTCODE);
await picker.locator(`button[title='${REACTION_SHORTCODE}']`).first().click();
const reactionImg = row.locator(`img[alt=':${REACTION_SHORTCODE}:']`);
await expect(reactionImg).toBeVisible();
});