mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -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 shortcode→url 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 shortcode→url 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 shortcode→image
|
||||
* 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>;
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user