diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 9b43b98e7..6609eb680 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -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 diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 0b0bf622e..97edd8803 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -402,6 +402,7 @@ pub async fn edit_message( event_id: String, content: String, media_tags: Vec>, + emoji_tags: Option>>, 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(()) } diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index 1a814562a..661f6fadb 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -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], + custom_emoji_tags: &[Vec], ) -> Result { 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)) } diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index b383f5ea6..22d9c2c94 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -780,6 +780,9 @@ export function AppShell() { onSetUserStatus={(text, emoji) => setUserStatusMutation.mutate({ text, emoji }) } + onClearUserStatus={() => + setUserStatusMutation.mutate({ text: "", emoji: "" }) + } profile={profileQuery.data} selfUserStatus={ deferredPubkey diff --git a/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx new file mode 100644 index 000000000..f588a358c --- /dev/null +++ b/desktop/src/features/custom-emoji/ui/EmojiPicker.tsx @@ -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 ( + { + // 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" + /> + ); +}); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts index 893bd4031..7a893ecbb 100644 --- a/desktop/src/features/messages/hooks.ts +++ b/desktop/src/features/messages/hooks.ts @@ -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) { diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts b/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts index c0c463ce9..5b19a3c8f 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.d.mts @@ -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[], diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs index 7545f4d87..becd3203b 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.mjs @@ -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]; } diff --git a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs index d0f857c9c..783586be6 100644 --- a/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs +++ b/desktop/src/features/messages/lib/applyEditTagOverlay.test.mjs @@ -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"], + ); +}); diff --git a/desktop/src/features/messages/lib/customEmojiNode.test.mjs b/desktop/src/features/messages/lib/customEmojiNode.test.mjs index 612ae86ae..05dd44462 100644 --- a/desktop/src/features/messages/lib/customEmojiNode.test.mjs +++ b/desktop/src/features/messages/lib/customEmojiNode.test.mjs @@ -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); +}); diff --git a/desktop/src/features/messages/lib/customEmojiNode.ts b/desktop/src/features/messages/lib/customEmojiNode.ts index e9c0fd4a9..d8beec86f 100644 --- a/desktop/src/features/messages/lib/customEmojiNode.ts +++ b/desktop/src/features/messages/lib/customEmojiNode.ts @@ -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 + * `` (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 round-trips cleanly. + return `:${esc(shortcode)}:`; + }; +} + export const CustomEmojiNode = Node.create({ name: CUSTOM_EMOJI_NODE_NAME, @@ -132,7 +218,22 @@ export const CustomEmojiNode = Node.create({ ) { 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 `` + // — 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); + }, + }, }, }; }, diff --git a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx index f1781a165..b13ac8122 100644 --- a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx +++ b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx @@ -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 ( @@ -56,25 +47,7 @@ export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({ side="top" sideOffset={10} > - { - // 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} - /> + ); diff --git a/desktop/src/features/messages/ui/MessageActionBar.tsx b/desktop/src/features/messages/ui/MessageActionBar.tsx index 86be9489b..4043bdbea 100644 --- a/desktop/src/features/messages/ui/MessageActionBar.tsx +++ b/desktop/src/features/messages/ui/MessageActionBar.tsx @@ -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({

) : null} - { + 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} /> diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 9d10444df..31251dc14 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -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({ Attach image ) : null} - { - void handleReactionSelect(emoji.native).finally(() => { + { + void handleReactionSelect(value).finally(() => { setIsReactionPickerOpen(false); }); }} - theme="auto" - previewPosition="none" - skinTonePosition="search" - set="native" - maxFrequentRows={2} - perLine={8} /> diff --git a/desktop/src/features/profile/ui/ProfilePopover.tsx b/desktop/src/features/profile/ui/ProfilePopover.tsx index d29134264..f91202d5a 100644 --- a/desktop/src/features/profile/ui/ProfilePopover.tsx +++ b/desktop/src/features/profile/ui/ProfilePopover.tsx @@ -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"; import { isMacPlatform } from "@/shared/lib/platform"; @@ -17,6 +18,7 @@ interface ProfilePopoverProps { open: boolean; onOpenChange: (open: boolean) => void; displayName: string; + nip05?: string | null; avatarUrl: string | null; currentStatus: PresenceStatus; isStatusPending?: boolean; @@ -24,6 +26,7 @@ interface ProfilePopoverProps { userStatusEmoji?: string; onSetStatus: (status: PresenceStatus) => void; onSetUserStatus: (text: string, emoji: string) => void; + onClearUserStatus: () => void; onOpenSettings: (section?: "profile" | "appearance") => void; children: React.ReactNode; // Optional outer container whose clicks should NOT close the popover. @@ -53,6 +56,7 @@ export function ProfilePopover({ open, onOpenChange, displayName, + nip05, avatarUrl, currentStatus, isStatusPending, @@ -60,6 +64,7 @@ export function ProfilePopover({ userStatusEmoji, onSetStatus, onSetUserStatus, + onClearUserStatus, onOpenSettings, children, triggerContainerRef, @@ -150,13 +155,31 @@ export function ProfilePopover({

{displayName}

-

- - {getPresenceLabel(currentStatus)} -

+
+ {nip05 ? @{nip05} : null} + {nip05 ? : null} + + + {getPresenceLabel(currentStatus)} + +
+ {hasUserStatus ? ( +

+ {userStatusEmoji ? ( + + ) : null} + {userStatusText} +

+ ) : null} @@ -299,6 +322,7 @@ export function ProfilePopover({ hasExistingStatus={hasUserStatus} initialEmoji={userStatusEmoji} initialText={userStatusText} + onClear={onClearUserStatus} onOpenChange={setStatusDialogOpen} onSave={onSetUserStatus} open={statusDialogOpen} diff --git a/desktop/src/features/profile/ui/UserProfilePanel.tsx b/desktop/src/features/profile/ui/UserProfilePanel.tsx index 3527612fa..c2ad07414 100644 --- a/desktop/src/features/profile/ui/UserProfilePanel.tsx +++ b/desktop/src/features/profile/ui/UserProfilePanel.tsx @@ -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 ? (

{userStatus.emoji ? ( - {userStatus.emoji} + ) : null} {userStatus.text}

diff --git a/desktop/src/features/profile/ui/UserProfilePopover.tsx b/desktop/src/features/profile/ui/UserProfilePopover.tsx index 656a05487..04733802b 100644 --- a/desktop/src/features/profile/ui/UserProfilePopover.tsx +++ b/desktop/src/features/profile/ui/UserProfilePopover.tsx @@ -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 ? ( - {userStatus.emoji} + ) : null} {userStatus.text}

diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index 40a036116..d3c291a28 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -138,6 +138,7 @@ type AppSidebarProps = { onSelectSettings: (section?: "profile" | "appearance") => void; onSetPresenceStatus?: (status: "online" | "away" | "offline") => void; onSetUserStatus: (text: string, emoji: string) => void; + onClearUserStatus: () => void; onSwitchWorkspace: (id: string) => void; selfUserStatus?: UserStatus; isPresencePending?: boolean; @@ -192,6 +193,7 @@ export function AppSidebar({ onSelectSettings, onSetPresenceStatus, onSetUserStatus, + onClearUserStatus, onSwitchWorkspace, selfUserStatus, isPresencePending, @@ -665,6 +667,7 @@ export function AppSidebar({ onRemoveWorkspace={onRemoveWorkspace} onSetPresenceStatus={onSetPresenceStatus} onSetUserStatus={onSetUserStatus} + onClearUserStatus={onClearUserStatus} onSwitchWorkspace={onSwitchWorkspace} onUpdateWorkspace={onUpdateWorkspace} profile={profile} diff --git a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx index 6a2d0d164..ac1985a3b 100644 --- a/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx +++ b/desktop/src/features/sidebar/ui/SidebarProfileCard.tsx @@ -4,6 +4,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 type { Workspace } from "@/features/workspaces/types"; import { WorkspaceSwitcher } from "@/features/workspaces/ui/WorkspaceSwitcher"; import type { PresenceStatus, Profile, UserStatus } from "@/shared/api/types"; @@ -17,6 +18,7 @@ type SidebarProfileCardProps = { onRemoveWorkspace: (id: string) => void; onSetPresenceStatus?: (status: PresenceStatus) => void; onSetUserStatus: (text: string, emoji: string) => void; + onClearUserStatus: () => void; onSwitchWorkspace: (id: string) => void; onUpdateWorkspace: ( id: string, @@ -37,6 +39,7 @@ export function SidebarProfileCard({ onRemoveWorkspace, onSetPresenceStatus, onSetUserStatus, + onClearUserStatus, onSwitchWorkspace, onUpdateWorkspace, profile, @@ -116,6 +119,7 @@ export function SidebarProfileCard({ open={profilePopoverOpen} onOpenChange={setProfilePopoverOpen} displayName={resolvedDisplayName} + nip05={profile?.nip05Handle} avatarUrl={profile?.avatarUrl ?? null} currentStatus={selfPresenceStatus} isStatusPending={isPresencePending} @@ -123,6 +127,7 @@ export function SidebarProfileCard({ userStatusEmoji={selfUserStatus?.emoji} onSetStatus={onSetPresenceStatus ?? (() => {})} onSetUserStatus={onSetUserStatus} + onClearUserStatus={onClearUserStatus} onOpenSettings={onOpenSettings} triggerContainerRef={profileCardRef} workspaceSwitcherSlot={ @@ -171,7 +176,10 @@ export function SidebarProfileCard({ type="button" > {selfUserStatus?.emoji ? ( - {selfUserStatus.emoji} + ) : null} {selfUserStatus?.text} diff --git a/desktop/src/features/user-status/ui/SetStatusDialog.tsx b/desktop/src/features/user-status/ui/SetStatusDialog.tsx index d4e0ec4d7..337aedf23 100644 --- a/desktop/src/features/user-status/ui/SetStatusDialog.tsx +++ b/desktop/src/features/user-status/ui/SetStatusDialog.tsx @@ -1,9 +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 { X } from "lucide-react"; +import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker"; +import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { Dialog, DialogContent, @@ -33,6 +32,7 @@ type SetStatusDialogProps = { initialText?: string; initialEmoji?: string; onSave: (text: string, emoji: string) => void; + onClear: () => void; hasExistingStatus: boolean; }; @@ -46,6 +46,7 @@ export function SetStatusDialog({ initialText = "", initialEmoji = "", onSave, + onClear, hasExistingStatus, }: SetStatusDialogProps) { const [text, setText] = React.useState(initialText); @@ -64,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); } @@ -74,9 +75,9 @@ export function SetStatusDialog({ onOpenChange(false); } - function handleClearDraft() { - setText(""); - setEmoji(""); + function handleClear() { + onClear(); + onOpenChange(false); } function handleKeyDown(event: React.KeyboardEvent) { @@ -100,54 +101,52 @@ export function SetStatusDialog({
-
+
- - - +
+ + + + {emoji ? ( + + ) : null} +
- +
setText(event.target.value)} onKeyDown={handleKeyDown} placeholder="What's your status?" value={text} /> - {hasExistingStatus || text || emoji ? ( - - ) : null}
@@ -164,7 +163,20 @@ export function SetStatusDialog({ ))}
-
+
+
+ {hasExistingStatus ? ( + + ) : null} +