From ca3f8fcf1602cc1c7790981af7a3d805e8a809e5 Mon Sep 17 00:00:00 2001 From: Sam Westerman Date: Thu, 30 Jul 2026 13:22:26 -0700 Subject: [PATCH 1/3] feat(desktop): IRC-style s/old/new/ self-correction in the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sending a message whose entire body is a well-formed substitution command (e.g. `s/hullo/hello/`) now edits your most recent message instead of sending literal text — a keyboard-only shorthand for the existing right-click "Edit message" gesture. It is pure client-side sugar: the correction is republished through the same kind-40003 edit path a manual edit uses, so other clients just see a normal edit. No relay, schema, or Rust changes. - `selfCorrection.ts` (pure, unit-tested): parses `soldnew[flags]` as literal text (not regex), sed-style so any punctuation delimiter works (`s|a|b|`, `s#a#b#`), with `\`-escaping of the delimiter, an optional trailing delimiter (`s/u/e`), and `g` (global) / `i` (case-insensitive) flags; and applies it. - `selfCorrectionEdit.ts` (pure, unit-tested): resolves a command against the timeline — finds the author's most recent editable message (same eligibility as ArrowUp-to-edit) and rebuilds the edit through the existing imeta/emoji helpers so attachments and custom emoji are preserved. - `useSelfCorrectingSend` hook intercepts at the send boundary in ChannelPane: a command edits the previous message; anything that does not parse or match falls through and sends literally. Drafts with their own attachments are treated as ordinary sends. A typo-fix correction intentionally emits no new mention tags. Scoped to the main timeline; thread-reply correction can follow. Signed-off-by: Sam Westerman --- .../src/features/channels/ui/ChannelPane.tsx | 10 + .../features/channels/ui/ChannelPane.types.ts | 10 + .../features/channels/ui/ChannelScreen.tsx | 4 + .../channels/useChannelPaneHandlers.ts | 22 ++ .../messages/lib/selfCorrection.test.mjs | 193 +++++++++++++++++ .../features/messages/lib/selfCorrection.ts | 196 ++++++++++++++++++ .../messages/lib/selfCorrectionEdit.test.mjs | 114 ++++++++++ .../messages/lib/selfCorrectionEdit.ts | 108 ++++++++++ .../messages/ui/useSelfCorrectingSend.ts | 57 +++++ 9 files changed, 714 insertions(+) create mode 100644 desktop/src/features/messages/lib/selfCorrection.test.mjs create mode 100644 desktop/src/features/messages/lib/selfCorrection.ts create mode 100644 desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs create mode 100644 desktop/src/features/messages/lib/selfCorrectionEdit.ts create mode 100644 desktop/src/features/messages/ui/useSelfCorrectingSend.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 70877875f7..baf9bc4848 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -11,6 +11,7 @@ import { isModerationDm } from "@/features/moderation/lib/moderationDm"; import { useRelaySelfQuery } from "@/features/moderation/hooks"; import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; +import { useSelfCorrectingSend } from "@/features/messages/ui/useSelfCorrectingSend"; import { MessageThreadPanelSkeleton } from "@/features/messages/ui/MessageThreadPanelSkeleton"; import { MessageTimeline, @@ -113,6 +114,7 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete, onEdit, onEditSave, + onEditSaveById, onFollowThread, onMarkUnread, onMarkRead, @@ -360,6 +362,11 @@ export const ChannelPane = React.memo(function ChannelPane({ clearWelcomeComposerDismissTimer, isActiveWelcomeChannel, ]); + const trySelfCorrectingSend = useSelfCorrectingSend({ + messages, + onEditSaveById, + currentPubkey, + }); const handleSendMessage = React.useCallback( async ( content: string, @@ -372,6 +379,8 @@ export const ChannelPane = React.memo(function ChannelPane({ (containsWelcomePersonaMention(content) || mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); + // `s/old/new/` self-correction edits your last message, not a new send. + if (await trySelfCorrectingSend(content, mediaTags)) return; messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage(content, mentionPubkeys, mediaTags, channelId); @@ -395,6 +404,7 @@ export const ChannelPane = React.memo(function ChannelPane({ isActiveWelcomeChannel, knownAgentPubkeys, onSendMessage, + trySelfCorrectingSend, ], ); const canDropInMainColumn = diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 7257d8cd55..4b33e1f200 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -88,6 +88,16 @@ export type ChannelPaneProps = { mediaTags?: string[][], mentionPubkeys?: string[], ) => Promise; + /** + * Publishes an edit to an explicit message id (for `s/old/new/` + * self-correction), without entering interactive edit mode. + */ + onEditSaveById?: ( + eventId: string, + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; onExpandThreadReplies: (message: TimelineMessage) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 7b750daa4f..9eee91e1ae 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -490,6 +490,7 @@ export function ChannelScreen({ handleDelete, handleEdit, handleEditSave, + handleEditSaveById, handleExpandThreadReplies, handleOpenThread, handleSendMessage, @@ -911,6 +912,9 @@ export function ChannelScreen({ onEditSave={ activeChannel?.archivedAt ? undefined : handleEditSave } + onEditSaveById={ + activeChannel?.archivedAt ? undefined : handleEditSaveById + } onMarkUnread={handleMessageMarkUnread} onMarkRead={handleMessageMarkRead} onExpandThreadReplies={handleExpandThreadReplies} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 8c5f54fffc..08af456292 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -165,6 +165,27 @@ export function useChannelPaneHandlers({ [setEditTargetId], ); + // Publishes an edit to an explicit message id, bypassing the interactive + // edit-mode state (`editTargetId`). Used by IRC-style `s/old/new/` + // self-correction, which edits the author's previous message without ever + // loading it into the composer. + const handleEditSaveById = React.useCallback( + async ( + eventId: string, + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + ) => { + await editMutateRef.current({ + eventId, + content, + mediaTags, + mentionPubkeys, + }); + }, + [], + ); + const handleOpenThread = React.useCallback( (message: { id: string }) => { if (openThreadHeadIdRef.current === message.id) { @@ -343,6 +364,7 @@ export function useChannelPaneHandlers({ handleDelete, handleEdit, handleEditSave, + handleEditSaveById, handleExpandThreadReplies, handleOpenThread, handleSendMessage, diff --git a/desktop/src/features/messages/lib/selfCorrection.test.mjs b/desktop/src/features/messages/lib/selfCorrection.test.mjs new file mode 100644 index 0000000000..67fba306c4 --- /dev/null +++ b/desktop/src/features/messages/lib/selfCorrection.test.mjs @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { applySelfCorrection, parseSelfCorrection } from "./selfCorrection.ts"; + +// ── parseSelfCorrection ──────────────────────────────────────────────────── + +test("parses a basic s/old/new/ command", () => { + assert.deepEqual(parseSelfCorrection("s/hullo/hello/"), { + pattern: "hullo", + replacement: "hello", + global: false, + ignoreCase: false, + }); +}); + +test("trailing delimiter is optional when there are no flags", () => { + assert.deepEqual(parseSelfCorrection("s/hullo/hello"), { + pattern: "hullo", + replacement: "hello", + global: false, + ignoreCase: false, + }); +}); + +test("empty replacement is a valid deletion", () => { + assert.deepEqual(parseSelfCorrection("s/oops//"), { + pattern: "oops", + replacement: "", + global: false, + ignoreCase: false, + }); +}); + +test("accepts alternate punctuation delimiters", () => { + for (const cmd of ["s|a|b|", "s#a#b#", "s,a,b,", "s:a:b:"]) { + assert.deepEqual( + parseSelfCorrection(cmd), + { pattern: "a", replacement: "b", global: false, ignoreCase: false }, + cmd, + ); + } +}); + +test("parses g and i flags in any order", () => { + assert.deepEqual(parseSelfCorrection("s/a/b/g"), { + pattern: "a", + replacement: "b", + global: true, + ignoreCase: false, + }); + assert.deepEqual(parseSelfCorrection("s/a/b/gi"), { + pattern: "a", + replacement: "b", + global: true, + ignoreCase: true, + }); + assert.deepEqual(parseSelfCorrection("s/a/b/ig"), { + pattern: "a", + replacement: "b", + global: true, + ignoreCase: true, + }); +}); + +test("honours escaped delimiters and backslashes inside sections", () => { + assert.deepEqual(parseSelfCorrection("s/a\\/b/c\\/d/"), { + pattern: "a/b", + replacement: "c/d", + global: false, + ignoreCase: false, + }); + assert.deepEqual(parseSelfCorrection("s/a\\\\b/c/"), { + pattern: "a\\b", + replacement: "c", + global: false, + ignoreCase: false, + }); +}); + +test("non-delimiter backslashes are preserved verbatim", () => { + assert.deepEqual(parseSelfCorrection("s/a/c\\nd/"), { + pattern: "a", + replacement: "c\\nd", + global: false, + ignoreCase: false, + }); +}); + +test("returns null for non-commands so they send as literal text", () => { + for (const input of [ + "", + "s", + "s/", + "s//", + "s/a", // no closing delimiter for the pattern + "s///", // empty pattern + "s3://bucket/key", // alphanumeric delimiter → not a command + "she said s/x/y/", // does not start with the command + "hello world", + "s a b", // whitespace delimiter → not a command + "s\\a\\b\\", // backslash delimiter → not a command + "s/a/b/x", // unknown flag + "s/a/b/gg", // repeated flag + ]) { + assert.equal(parseSelfCorrection(input), null, JSON.stringify(input)); + } +}); + +// ── applySelfCorrection ──────────────────────────────────────────────────── + +const cmd = (overrides) => ({ + pattern: "a", + replacement: "b", + global: false, + ignoreCase: false, + ...overrides, +}); + +test("replaces the first occurrence by default", () => { + assert.equal( + applySelfCorrection("banana", cmd({ pattern: "a", replacement: "o" })), + "bonana", + ); +}); + +test("global flag replaces every occurrence", () => { + assert.equal( + applySelfCorrection( + "banana", + cmd({ pattern: "a", replacement: "o", global: true }), + ), + "bonono", + ); +}); + +test("does not re-match inside the substituted text", () => { + // Replacing "a" with "aa" globally must not loop forever or double up. + assert.equal( + applySelfCorrection( + "aa", + cmd({ pattern: "a", replacement: "aa", global: true }), + ), + "aaaa", + ); +}); + +test("ignoreCase matches case-insensitively but preserves surrounding text", () => { + assert.equal( + applySelfCorrection( + "The HULLO there", + cmd({ pattern: "hullo", replacement: "hello", ignoreCase: true }), + ), + "The hello there", + ); +}); + +test("returns null when the pattern is absent", () => { + assert.equal( + applySelfCorrection("hello", cmd({ pattern: "xyz", replacement: "q" })), + null, + ); +}); + +test("deletion removes the matched text", () => { + assert.equal( + applySelfCorrection("hel lo", cmd({ pattern: " ", replacement: "" })), + "hello", + ); +}); + +test("end-to-end: parse then apply the motivating example", () => { + const command = parseSelfCorrection("s/u/e/"); + assert.ok(command); + assert.equal(applySelfCorrection("hullo there!", command), "hello there!"); +}); + +test("end-to-end: trailing delimiter omitted — s/u/e", () => { + const command = parseSelfCorrection("s/u/e"); + assert.ok(command); + assert.equal(applySelfCorrection("hullo there!", command), "hello there!"); +}); + +test("end-to-end: escaped delimiters — s/\\//\\/\\//g doubles every slash", () => { + const command = parseSelfCorrection("s/\\//\\/\\//g"); + assert.deepEqual(command, { + pattern: "/", + replacement: "//", + global: true, + ignoreCase: false, + }); + assert.equal(applySelfCorrection("a/b/c", command), "a//b//c"); +}); diff --git a/desktop/src/features/messages/lib/selfCorrection.ts b/desktop/src/features/messages/lib/selfCorrection.ts new file mode 100644 index 0000000000..3534568027 --- /dev/null +++ b/desktop/src/features/messages/lib/selfCorrection.ts @@ -0,0 +1,196 @@ +/** + * IRC-style `s/old/new/` self-correction. + * + * When a user sends a message whose entire body is a well-formed substitution + * command, the composer treats it as shorthand for editing their most recent + * message instead of sending literal text — the same gesture as right-click + * "Edit message", just faster to type. This module is the pure, UI-free core: + * it parses the command and applies it to a string. All the wiring (finding the + * target message, republishing the edit event) lives in the composer. + * + * Grammar (sed-flavoured, but **literal text — never regex**): + * + * spatternreplacement + * + * - `` is the delimiter: the single character immediately following `s`. It + * may be any punctuation character, so `s/a/b/`, `s|a|b|`, and `s#a#b#` are + * all equivalent. Requiring a punctuation delimiter (not a letter/digit) + * keeps ordinary prose like "s3 bucket" from being mistaken for a command. + * - The trailing delimiter is optional when there are no flags (`s/a/b` works). + * - `\` inside pattern/replacement is a literal delimiter; `\\` is a literal + * backslash. Every other backslash is kept verbatim. + * - Flags: `g` (replace every occurrence, not just the first) and `i` + * (case-insensitive match). Each may appear at most once, in any order. + * + * Anything that does not parse cleanly returns `null`, so the caller falls back + * to sending the text literally — a mistyped command is never silently eaten. + */ + +export type SelfCorrectionCommand = { + /** Literal text to search for. Guaranteed non-empty. */ + pattern: string; + /** Literal text to substitute in. May be empty (a deletion). */ + replacement: string; + /** Replace every occurrence rather than only the first. */ + global: boolean; + /** Match case-insensitively. */ + ignoreCase: boolean; +}; + +/** + * A delimiter must be a single non-alphanumeric, non-whitespace, non-backslash + * character. This is deliberately conservative: it lets `/ | # : , ~` etc. work + * while ensuring a bare word starting with "s" can never look like a command. + */ +function isValidDelimiter(char: string): boolean { + return /^[^\sA-Za-z0-9\\]$/.test(char); +} + +/** + * Scan a delimited section starting at `start`, honouring `\` and `\\` + * escapes. Returns the unescaped section text and the index of the character + * immediately after the closing delimiter, or `null` if no closing delimiter + * is found before the end of the string. + */ +function scanSection( + input: string, + start: number, + delimiter: string, +): { value: string; next: number } | null { + let value = ""; + let index = start; + while (index < input.length) { + const char = input[index]; + if (char === "\\" && index + 1 < input.length) { + const escaped = input[index + 1]; + if (escaped === delimiter || escaped === "\\") { + value += escaped; + index += 2; + continue; + } + } + if (char === delimiter) { + return { value, next: index + 1 }; + } + value += char; + index += 1; + } + return null; +} + +/** + * Parse a trimmed message body as a self-correction command. Returns `null` + * when the text is not a well-formed command (including the empty pattern + * case), signalling the caller to treat the text as an ordinary message. + */ +export function parseSelfCorrection( + input: string, +): SelfCorrectionCommand | null { + // `s` + a delimiter + at least a closing delimiter is the minimum shape. + if (input.length < 3 || input[0] !== "s") { + return null; + } + const delimiter = input[1]; + if (!isValidDelimiter(delimiter)) { + return null; + } + + const patternSection = scanSection(input, 2, delimiter); + if (!patternSection || patternSection.value.length === 0) { + return null; + } + + const replacementSection = scanSection(input, patternSection.next, delimiter); + + let replacement: string; + let flagsStart: number; + if (replacementSection) { + replacement = replacementSection.value; + flagsStart = replacementSection.next; + } else { + // No closing delimiter after the replacement: allowed only when it runs to + // the end of the string (the trailing-delimiter-omitted form, no flags). + // Re-scan without requiring a terminator, unescaping as we go. + replacement = unescapeToEnd(input, patternSection.next, delimiter); + flagsStart = input.length; + } + + let global = false; + let ignoreCase = false; + for (let index = flagsStart; index < input.length; index += 1) { + const flag = input[index]; + if (flag === "g" && !global) { + global = true; + } else if (flag === "i" && !ignoreCase) { + ignoreCase = true; + } else { + return null; + } + } + + return { pattern: patternSection.value, replacement, global, ignoreCase }; +} + +/** Unescape `\` / `\\` from `start` to end of string. */ +function unescapeToEnd( + input: string, + start: number, + delimiter: string, +): string { + let value = ""; + let index = start; + while (index < input.length) { + const char = input[index]; + if (char === "\\" && index + 1 < input.length) { + const escaped = input[index + 1]; + if (escaped === delimiter || escaped === "\\") { + value += escaped; + index += 2; + continue; + } + } + value += char; + index += 1; + } + return value; +} + +/** + * Apply a parsed command to `text`. Returns the corrected string, or `null` + * when the pattern does not occur in `text` (nothing to correct — the caller + * should fall back to sending the text literally). + */ +export function applySelfCorrection( + text: string, + command: SelfCorrectionCommand, +): string | null { + const { pattern, replacement, global, ignoreCase } = command; + // Search over a case-folded copy when ignoring case, but splice from the + // original so the surrounding text keeps its casing. Index alignment holds + // for the common (ASCII / BMP) case; exotic case-folding that changes length + // is not supported and is acceptably rare for a typo-fix affordance. + const haystack = ignoreCase ? text.toLowerCase() : text; + const needle = ignoreCase ? pattern.toLowerCase() : pattern; + + let matchAt = haystack.indexOf(needle); + if (matchAt === -1) { + return null; + } + + let result = ""; + let copiedUpTo = 0; + while (matchAt !== -1) { + result += text.slice(copiedUpTo, matchAt) + replacement; + copiedUpTo = matchAt + pattern.length; + if (!global) { + break; + } + // Resume past this match so replacements never overlap or re-match inside + // the substituted text. `needle.length === pattern.length` (case-folding + // preserves length for the supported character range), so `copiedUpTo` is a + // valid index into `haystack`. + matchAt = haystack.indexOf(needle, copiedUpTo); + } + result += text.slice(copiedUpTo); + return result; +} diff --git a/desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs b/desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs new file mode 100644 index 0000000000..e650ae84b5 --- /dev/null +++ b/desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + findLastOwnCorrectable, + resolveSelfCorrection, +} from "./selfCorrectionEdit.ts"; + +const KIND_SYSTEM_MESSAGE = 40099; + +const msg = (overrides) => ({ + id: "id", + createdAt: 1, + pubkey: "me", + body: "", + pending: false, + tags: [], + ...overrides, +}); + +// ── findLastOwnCorrectable ────────────────────────────────────────────────── + +test("picks the author's most recent editable message", () => { + const target = findLastOwnCorrectable( + [ + msg({ id: "a", pubkey: "me", createdAt: 1 }), + msg({ id: "b", pubkey: "me", createdAt: 3 }), + msg({ id: "c", pubkey: "me", createdAt: 2 }), + ], + "me", + ); + assert.equal(target?.id, "b"); +}); + +test("ignores others' messages, system rows, and pending sends", () => { + assert.equal( + findLastOwnCorrectable( + [ + msg({ id: "other", pubkey: "you", createdAt: 9 }), + msg({ + id: "system", + pubkey: "me", + kind: KIND_SYSTEM_MESSAGE, + createdAt: 8, + }), + msg({ id: "pending", pubkey: "me", pending: true, createdAt: 7 }), + msg({ id: "mine", pubkey: "me", createdAt: 2 }), + ], + "me", + )?.id, + "mine", + ); +}); + +test("returns null when the author has no eligible message", () => { + assert.equal(findLastOwnCorrectable([msg({ pubkey: "you" })], "me"), null); +}); + +// ── resolveSelfCorrection ─────────────────────────────────────────────────── + +test("resolves a plain-text correction to the target's edit", () => { + const edit = resolveSelfCorrection( + "s/hullo/hello/", + [msg({ id: "m1", pubkey: "me", body: "hullo there" })], + "me", + [], + ); + assert.deepEqual(edit, { eventId: "m1", content: "hello there", tags: [] }); +}); + +test("returns null when the text is not a command", () => { + assert.equal( + resolveSelfCorrection( + "just a normal message", + [msg({ id: "m1", pubkey: "me", body: "hi" })], + "me", + [], + ), + null, + ); +}); + +test("returns null when there is no editable target", () => { + assert.equal( + resolveSelfCorrection("s/a/b/", [msg({ pubkey: "you" })], "me", []), + null, + ); +}); + +test("returns null when the pattern is absent from the target", () => { + assert.equal( + resolveSelfCorrection( + "s/xyz/q/", + [msg({ id: "m1", pubkey: "me", body: "hello" })], + "me", + [], + ), + null, + ); +}); + +test("re-attaches NIP-30 emoji tags for shortcodes in the corrected body", () => { + const edit = resolveSelfCorrection( + "s/hi/yo/", + [msg({ id: "m1", pubkey: "me", body: "hi :bee:" })], + "me", + [{ shortcode: "bee", url: "https://cdn/bee.png" }], + ); + assert.equal(edit?.content, "yo :bee:"); + assert.ok( + edit?.tags.some((tag) => tag[0] === "emoji" && tag[1] === "bee"), + "expected an emoji tag for :bee:", + ); +}); diff --git a/desktop/src/features/messages/lib/selfCorrectionEdit.ts b/desktop/src/features/messages/lib/selfCorrectionEdit.ts new file mode 100644 index 0000000000..d2fa009bff --- /dev/null +++ b/desktop/src/features/messages/lib/selfCorrectionEdit.ts @@ -0,0 +1,108 @@ +/** + * Resolves an IRC-style `s/old/new/` self-correction against a channel's + * timeline into a ready-to-publish edit. + * + * This is the bridge between the pure command grammar in `selfCorrection.ts` + * and the existing kind-40003 edit path: it finds the author's most recent + * editable message, applies the substitution to its human-visible body, and + * rebuilds the outgoing edit — preserving imeta attachments and NIP-30 custom + * emoji exactly like the manual edit-save path does. Kept UI-free so it can be + * unit-tested; the composing hook only wires state and publishes the result. + * + * Mentions: a typo-fix correction intentionally emits no new mention `p` tags + * (the corrected body keeps the original mention tokens, so nobody is re-woken + * and existing mentions still render). A substitution that introduces a + * brand-new `@mention` will therefore not notify that user — an accepted v1 + * limitation, since the shortcut targets quick self-corrections. + */ + +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; +import type { TimelineMessage } from "@/features/messages/types"; +import { + buildOutgoingMessage, + findSpoileredImetaMediaUrls, + imetaMediaFromTags, + mergeOutgoingTags, + restoreImetaMediaDisplayLabels, + stripImetaMediaLines, +} from "@/features/messages/lib/imetaMediaMarkdown"; +import { + applySelfCorrection, + parseSelfCorrection, +} from "@/features/messages/lib/selfCorrection"; + +/** A published edit derived from a self-correction command. */ +export type SelfCorrectionEdit = { + eventId: string; + content: string; + tags: string[][]; +}; + +/** + * The author's most recent message that a self-correction may target, mirroring + * the eligibility rules of ArrowUp-to-edit: own, non-system, not still pending. + */ +export function findLastOwnCorrectable( + candidates: readonly TimelineMessage[], + currentPubkey: string, +): TimelineMessage | null { + let best: TimelineMessage | null = null; + for (const message of candidates) { + if ( + message.kind === KIND_SYSTEM_MESSAGE || + message.pubkey !== currentPubkey || + message.pending + ) { + continue; + } + if (!best || message.createdAt >= best.createdAt) { + best = message; + } + } + return best; +} + +/** + * Resolve `text` as a self-correction against `candidates`. Returns the edit to + * publish, or `null` when `text` is not a command, there is no editable target, + * or the pattern does not occur in the target (all of which mean "send `text` + * literally instead"). + */ +export function resolveSelfCorrection( + text: string, + candidates: readonly TimelineMessage[], + currentPubkey: string, + customEmoji: ReadonlyArray, +): SelfCorrectionEdit | null { + const command = parseSelfCorrection(text.trim()); + if (!command) return null; + + const target = findLastOwnCorrectable(candidates, currentPubkey); + if (!target) return null; + + // Rebuild the human-editable body (imeta markdown lines stripped) so the + // substitution runs over what the author actually sees, then reassemble the + // outgoing edit the same way the manual edit-save path does. + const editableImeta = restoreImetaMediaDisplayLabels( + target.body, + imetaMediaFromTags(target.tags ?? []), + ); + const editableBody = stripImetaMediaLines(target.body, editableImeta); + const correctedBody = applySelfCorrection(editableBody, command); + if (correctedBody === null || correctedBody === editableBody) { + return null; + } + + const { content, mediaTags } = buildOutgoingMessage( + correctedBody, + editableImeta, + findSpoileredImetaMediaUrls(target.body, editableImeta), + ); + const tags = + mergeOutgoingTags(mediaTags, buildCustomEmojiTags(content, customEmoji)) ?? + []; + + return { eventId: target.id, content, tags }; +} diff --git a/desktop/src/features/messages/ui/useSelfCorrectingSend.ts b/desktop/src/features/messages/ui/useSelfCorrectingSend.ts new file mode 100644 index 0000000000..2beb93a888 --- /dev/null +++ b/desktop/src/features/messages/ui/useSelfCorrectingSend.ts @@ -0,0 +1,57 @@ +import * as React from "react"; + +import { useCustomEmoji } from "@/features/custom-emoji/hooks"; +import { resolveSelfCorrection } from "@/features/messages/lib/selfCorrectionEdit"; +import type { TimelineMessage } from "@/features/messages/types"; + +type EditSaveById = ( + eventId: string, + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], +) => Promise; + +/** + * Returns a guard for the send path: given the message a user is about to send, + * if its body is a well-formed `s/old/new/` command it edits the author's most + * recent message (via the existing kind-40003 edit path) and resolves `true`, + * telling the caller to skip the literal send. Otherwise resolves `false` and + * the caller sends normally. + * + * Skipped when the draft carries its own attachments (then `s/a/b/` is a + * caption, not a command) or when `onEditSaveById`/`currentPubkey` are absent. + */ +export function useSelfCorrectingSend(params: { + messages: readonly TimelineMessage[]; + onEditSaveById?: EditSaveById; + currentPubkey?: string; +}): (content: string, mediaTags: string[][] | undefined) => Promise { + const customEmoji = useCustomEmoji(); + const customEmojiRef = React.useRef(customEmoji); + customEmojiRef.current = customEmoji; + const messagesRef = React.useRef(params.messages); + messagesRef.current = params.messages; + const onEditSaveByIdRef = React.useRef(params.onEditSaveById); + onEditSaveByIdRef.current = params.onEditSaveById; + const currentPubkeyRef = React.useRef(params.currentPubkey); + currentPubkeyRef.current = params.currentPubkey; + + return React.useCallback(async (content, mediaTags) => { + const saveById = onEditSaveByIdRef.current; + const currentPubkey = currentPubkeyRef.current; + if (!saveById || !currentPubkey) return false; + // A draft with its own attachments is an ordinary send with a caption. + if (mediaTags && mediaTags.length > 0) return false; + + const edit = resolveSelfCorrection( + content, + messagesRef.current, + currentPubkey, + customEmojiRef.current, + ); + if (!edit) return false; + + await saveById(edit.eventId, edit.content, edit.tags); + return true; + }, []); +} From a665fb0b6b0146bbe025b3545af7c94da193cbe4 Mon Sep 17 00:00:00 2001 From: Sam Westerman Date: Thu, 30 Jul 2026 13:34:22 -0700 Subject: [PATCH 2/3] refactor(desktop): restrict self-correction delimiter to slash only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review feedback: arbitrary punctuation delimiters widened the "is this a command?" gate with no functional gain. sed allows any delimiter only to avoid escaping the delimiter inside the pattern — which the existing `\/` backslash-escaping already covers. Fixing the delimiter to `/` matches the canonical IRC `s/old/new/` shorthand and shrinks the accidental-trigger surface (prose like `s: notes`, `s|foo` is no longer mistaken for a command). Also adds an explicit test proving the pattern and replacement are literal text, never regex: `s/*/x` replaces the first literal `*`, `.` is a literal dot (not a wildcard), and `$1`/`\d` in the replacement are inserted verbatim. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Sam Westerman --- .../messages/lib/selfCorrection.test.mjs | 49 +++++++++++++++---- .../features/messages/lib/selfCorrection.ts | 29 +++++------ 2 files changed, 50 insertions(+), 28 deletions(-) diff --git a/desktop/src/features/messages/lib/selfCorrection.test.mjs b/desktop/src/features/messages/lib/selfCorrection.test.mjs index 67fba306c4..ac2a935b11 100644 --- a/desktop/src/features/messages/lib/selfCorrection.test.mjs +++ b/desktop/src/features/messages/lib/selfCorrection.test.mjs @@ -32,13 +32,12 @@ test("empty replacement is a valid deletion", () => { }); }); -test("accepts alternate punctuation delimiters", () => { - for (const cmd of ["s|a|b|", "s#a#b#", "s,a,b,", "s:a:b:"]) { - assert.deepEqual( - parseSelfCorrection(cmd), - { pattern: "a", replacement: "b", global: false, ignoreCase: false }, - cmd, - ); +test("only the canonical slash delimiter is a command", () => { + // Alternate punctuation delimiters are intentionally NOT commands — they widen + // the accidental-trigger surface for no gain (`\/` escaping already covers a + // slash inside the pattern). Slash-only matches the IRC `s/old/new/` shorthand. + for (const cmd of ["s|a|b|", "s#a#b#", "s,a,b,", "s:a:b:", "s~a~b~"]) { + assert.equal(parseSelfCorrection(cmd), null, cmd); } }); @@ -95,11 +94,13 @@ test("returns null for non-commands so they send as literal text", () => { "s//", "s/a", // no closing delimiter for the pattern "s///", // empty pattern - "s3://bucket/key", // alphanumeric delimiter → not a command + "s3://bucket/key", // second char isn't the slash delimiter → not a command "she said s/x/y/", // does not start with the command "hello world", - "s a b", // whitespace delimiter → not a command - "s\\a\\b\\", // backslash delimiter → not a command + "s: notes for later", // non-slash char after `s` → ordinary prose, not a command + "s|a|b|", // alternate delimiters are no longer commands + "s a b", // whitespace after `s` → not a command + "s\\a\\b\\", // backslash after `s` → not a command "s/a/b/x", // unknown flag "s/a/b/gg", // repeated flag ]) { @@ -169,6 +170,34 @@ test("deletion removes the matched text", () => { ); }); +test("pattern and replacement are literal text, never regex", () => { + // `s/*/x` replaces the first literal `*` with `x` — no regex metacharacters. + assert.equal( + applySelfCorrection("a*b*c", parseSelfCorrection("s/*/x")), + "axb*c", + ); + // `.` is a literal dot, not a wildcard: absent from "abc" → nothing to correct. + assert.equal(applySelfCorrection("abc", parseSelfCorrection("s/./X/")), null); + assert.equal( + applySelfCorrection("a.c", parseSelfCorrection("s/./X/")), + "aXc", + ); + // Other regex-special chars match themselves in both pattern and replacement. + assert.equal( + applySelfCorrection("1+1", parseSelfCorrection("s/+/ plus /")), + "1 plus 1", + ); + assert.equal( + applySelfCorrection("(a)", parseSelfCorrection("s/(a)/[b]/")), + "[b]", + ); + // Replacement is inserted verbatim — no `$1` backrefs or `\d` interpretation. + assert.equal( + applySelfCorrection("hi", parseSelfCorrection("s/hi/$1\\d/")), + "$1\\d", + ); +}); + test("end-to-end: parse then apply the motivating example", () => { const command = parseSelfCorrection("s/u/e/"); assert.ok(command); diff --git a/desktop/src/features/messages/lib/selfCorrection.ts b/desktop/src/features/messages/lib/selfCorrection.ts index 3534568027..f5f974a69d 100644 --- a/desktop/src/features/messages/lib/selfCorrection.ts +++ b/desktop/src/features/messages/lib/selfCorrection.ts @@ -12,10 +12,12 @@ * * spatternreplacement * - * - `` is the delimiter: the single character immediately following `s`. It - * may be any punctuation character, so `s/a/b/`, `s|a|b|`, and `s#a#b#` are - * all equivalent. Requiring a punctuation delimiter (not a letter/digit) - * keeps ordinary prose like "s3 bucket" from being mistaken for a command. + * - `` is the delimiter, and it is always `/` — the canonical IRC/sed + * shorthand (`s/old/new/`). sed itself allows any character as the delimiter, + * but the only thing that buys you is avoiding escapes when the delimiter + * appears in your pattern — which `\/` escaping already covers. Fixing it to + * `/` keeps ordinary prose (`s3 bucket`, `s: notes`, `s|foo`) from ever + * looking like a command, shrinking the accidental-trigger surface. * - The trailing delimiter is optional when there are no flags (`s/a/b` works). * - `\` inside pattern/replacement is a literal delimiter; `\\` is a literal * backslash. Every other backslash is kept verbatim. @@ -37,14 +39,8 @@ export type SelfCorrectionCommand = { ignoreCase: boolean; }; -/** - * A delimiter must be a single non-alphanumeric, non-whitespace, non-backslash - * character. This is deliberately conservative: it lets `/ | # : , ~` etc. work - * while ensuring a bare word starting with "s" can never look like a command. - */ -function isValidDelimiter(char: string): boolean { - return /^[^\sA-Za-z0-9\\]$/.test(char); -} +/** The one supported delimiter — the canonical IRC/sed `s/old/new/` slash. */ +const DELIMITER = "/"; /** * Scan a delimited section starting at `start`, honouring `\` and `\\` @@ -86,14 +82,11 @@ function scanSection( export function parseSelfCorrection( input: string, ): SelfCorrectionCommand | null { - // `s` + a delimiter + at least a closing delimiter is the minimum shape. - if (input.length < 3 || input[0] !== "s") { - return null; - } - const delimiter = input[1]; - if (!isValidDelimiter(delimiter)) { + // `s/` + at least a closing delimiter is the minimum shape. + if (input.length < 3 || input[0] !== "s" || input[1] !== DELIMITER) { return null; } + const delimiter = DELIMITER; const patternSection = scanSection(input, 2, delimiter); if (!patternSection || patternSection.value.length === 0) { From 12c7e5ae8beab8623621889762716978aabb75ca Mon Sep 17 00:00:00 2001 From: Sam Westerman Date: Thu, 30 Jul 2026 15:30:58 -0700 Subject: [PATCH 3/3] refactor(desktop): shrink self-correction to reuse the existing edit path Collapses the IRC `s/old/new/` self-correction surface without changing behaviour (still main-composer-only, literal-not-regex, media/emoji preserved). - Drop the new `onEditSaveById` handler + prop threaded through ChannelScreen -> ChannelPane.types -> ChannelPane -> hook. Instead widen the existing `handleEditSave`/`onEditSave` with an optional explicit `eventId`, so self-correction rides the edit path already in place. Zero new cross-component props. - Reuse ChannelPane's existing `findLastOwnEditable` instead of the duplicate `findLastOwnCorrectable`, so "what counts as editable" has one definition shared with ArrowUp-to-edit. - Merge `selfCorrectionEdit.ts` into `selfCorrection.ts` as `buildSelfCorrectionEdit`; fold its tests into one file. - Slim `useSelfCorrectingSend` to pure state-wiring over the tested lib. 9 files -> 6, 736 -> 595 lines. Full desktop suite green (3855 tests), typecheck + biome + file-size/px/pubkey guards clean. Co-Authored-By: Claude Opus 4.8 (1M context) Signed-off-by: Sam Westerman --- .../src/features/channels/ui/ChannelPane.tsx | 12 +- .../features/channels/ui/ChannelPane.types.ts | 13 +- .../features/channels/ui/ChannelScreen.tsx | 4 - .../channels/useChannelPaneHandlers.ts | 33 ++--- .../messages/lib/selfCorrection.test.mjs | 43 ++++++- .../features/messages/lib/selfCorrection.ts | 63 ++++++++++ .../messages/lib/selfCorrectionEdit.test.mjs | 114 ------------------ .../messages/lib/selfCorrectionEdit.ts | 108 ----------------- .../messages/ui/useSelfCorrectingSend.ts | 74 ++++++------ 9 files changed, 160 insertions(+), 304 deletions(-) delete mode 100644 desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs delete mode 100644 desktop/src/features/messages/lib/selfCorrectionEdit.ts diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index baf9bc4848..d405fba532 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -114,7 +114,6 @@ export const ChannelPane = React.memo(function ChannelPane({ onDelete, onEdit, onEditSave, - onEditSaveById, onFollowThread, onMarkUnread, onMarkRead, @@ -362,10 +361,11 @@ export const ChannelPane = React.memo(function ChannelPane({ clearWelcomeComposerDismissTimer, isActiveWelcomeChannel, ]); - const trySelfCorrectingSend = useSelfCorrectingSend({ + // `s/old/new/` self-correction — reuses findLastOwnEditable + onEditSave. + const maybeSelfCorrect = useSelfCorrectingSend({ messages, - onEditSaveById, - currentPubkey, + findLastOwnEditable, + onEditSave, }); const handleSendMessage = React.useCallback( async ( @@ -380,7 +380,7 @@ export const ChannelPane = React.memo(function ChannelPane({ mentionsKnownAgent(mentionPubkeys, knownAgentPubkeys)); // `s/old/new/` self-correction edits your last message, not a new send. - if (await trySelfCorrectingSend(content, mediaTags)) return; + if (await maybeSelfCorrect(content, mediaTags)) return; messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage(content, mentionPubkeys, mediaTags, channelId); @@ -403,8 +403,8 @@ export const ChannelPane = React.memo(function ChannelPane({ goChannel, isActiveWelcomeChannel, knownAgentPubkeys, + maybeSelfCorrect, onSendMessage, - trySelfCorrectingSend, ], ); const canDropInMainColumn = diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 4b33e1f200..87f992f728 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -87,16 +87,9 @@ export type ChannelPaneProps = { content: string, mediaTags?: string[][], mentionPubkeys?: string[], - ) => Promise; - /** - * Publishes an edit to an explicit message id (for `s/old/new/` - * self-correction), without entering interactive edit mode. - */ - onEditSaveById?: ( - eventId: string, - content: string, - mediaTags?: string[][], - mentionPubkeys?: string[], + // Explicit target id, bypassing interactive edit mode — used by + // `s/old/new/` self-correction to edit the author's previous message. + eventId?: string, ) => Promise; onMarkUnread?: (message: TimelineMessage) => void; onMarkRead?: (message: TimelineMessage) => void; diff --git a/desktop/src/features/channels/ui/ChannelScreen.tsx b/desktop/src/features/channels/ui/ChannelScreen.tsx index 9eee91e1ae..7b750daa4f 100644 --- a/desktop/src/features/channels/ui/ChannelScreen.tsx +++ b/desktop/src/features/channels/ui/ChannelScreen.tsx @@ -490,7 +490,6 @@ export function ChannelScreen({ handleDelete, handleEdit, handleEditSave, - handleEditSaveById, handleExpandThreadReplies, handleOpenThread, handleSendMessage, @@ -912,9 +911,6 @@ export function ChannelScreen({ onEditSave={ activeChannel?.archivedAt ? undefined : handleEditSave } - onEditSaveById={ - activeChannel?.archivedAt ? undefined : handleEditSaveById - } onMarkUnread={handleMessageMarkUnread} onMarkRead={handleMessageMarkRead} onExpandThreadReplies={handleExpandThreadReplies} diff --git a/desktop/src/features/channels/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 08af456292..08ccb9e4bd 100644 --- a/desktop/src/features/channels/useChannelPaneHandlers.ts +++ b/desktop/src/features/channels/useChannelPaneHandlers.ts @@ -148,14 +148,19 @@ export function useChannelPaneHandlers({ content: string, mediaTags?: string[][], mentionPubkeys?: string[], + // Explicit target, bypassing interactive edit mode: IRC-style + // `s/old/new/` self-correction edits the author's previous message + // without ever loading it into the composer. Defaults to the current + // edit-mode target. + eventId?: string, ) => { - const eventId = editTargetIdRef.current; - if (!eventId) { + const targetEventId = eventId ?? editTargetIdRef.current; + if (!targetEventId) { return; } await editMutateRef.current({ - eventId, + eventId: targetEventId, content, mediaTags, mentionPubkeys, @@ -165,27 +170,6 @@ export function useChannelPaneHandlers({ [setEditTargetId], ); - // Publishes an edit to an explicit message id, bypassing the interactive - // edit-mode state (`editTargetId`). Used by IRC-style `s/old/new/` - // self-correction, which edits the author's previous message without ever - // loading it into the composer. - const handleEditSaveById = React.useCallback( - async ( - eventId: string, - content: string, - mediaTags?: string[][], - mentionPubkeys?: string[], - ) => { - await editMutateRef.current({ - eventId, - content, - mediaTags, - mentionPubkeys, - }); - }, - [], - ); - const handleOpenThread = React.useCallback( (message: { id: string }) => { if (openThreadHeadIdRef.current === message.id) { @@ -364,7 +348,6 @@ export function useChannelPaneHandlers({ handleDelete, handleEdit, handleEditSave, - handleEditSaveById, handleExpandThreadReplies, handleOpenThread, handleSendMessage, diff --git a/desktop/src/features/messages/lib/selfCorrection.test.mjs b/desktop/src/features/messages/lib/selfCorrection.test.mjs index ac2a935b11..086be0453b 100644 --- a/desktop/src/features/messages/lib/selfCorrection.test.mjs +++ b/desktop/src/features/messages/lib/selfCorrection.test.mjs @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { applySelfCorrection, parseSelfCorrection } from "./selfCorrection.ts"; +import { + applySelfCorrection, + buildSelfCorrectionEdit, + parseSelfCorrection, +} from "./selfCorrection.ts"; // ── parseSelfCorrection ──────────────────────────────────────────────────── @@ -220,3 +224,40 @@ test("end-to-end: escaped delimiters — s/\\//\\/\\//g doubles every slash", () }); assert.equal(applySelfCorrection("a/b/c", command), "a//b//c"); }); + +// ── buildSelfCorrectionEdit ───────────────────────────────────────────────── + +const target = (overrides) => ({ body: "", tags: [], ...overrides }); + +test("buildSelfCorrectionEdit: rebuilds the corrected body as an edit", () => { + const edit = buildSelfCorrectionEdit( + target({ body: "hullo there" }), + parseSelfCorrection("s/hullo/hello/"), + [], + ); + assert.deepEqual(edit, { content: "hello there", tags: [] }); +}); + +test("buildSelfCorrectionEdit: null when the pattern is absent from the target", () => { + assert.equal( + buildSelfCorrectionEdit( + target({ body: "hello" }), + parseSelfCorrection("s/xyz/q/"), + [], + ), + null, + ); +}); + +test("buildSelfCorrectionEdit: re-attaches NIP-30 emoji tags for shortcodes in the corrected body", () => { + const edit = buildSelfCorrectionEdit( + target({ body: "hi :bee:" }), + parseSelfCorrection("s/hi/yo/"), + [{ shortcode: "bee", url: "https://cdn/bee.png" }], + ); + assert.equal(edit?.content, "yo :bee:"); + assert.ok( + edit?.tags.some((tag) => tag[0] === "emoji" && tag[1] === "bee"), + "expected an emoji tag for :bee:", + ); +}); diff --git a/desktop/src/features/messages/lib/selfCorrection.ts b/desktop/src/features/messages/lib/selfCorrection.ts index f5f974a69d..e8fe6ce484 100644 --- a/desktop/src/features/messages/lib/selfCorrection.ts +++ b/desktop/src/features/messages/lib/selfCorrection.ts @@ -26,8 +26,26 @@ * * Anything that does not parse cleanly returns `null`, so the caller falls back * to sending the text literally — a mistyped command is never silently eaten. + * + * `buildSelfCorrectionEdit` (bottom of file) bridges a parsed command and the + * existing kind-40003 edit path: it applies the substitution to a target + * message's visible body and rebuilds the outgoing edit, preserving imeta + * attachments and NIP-30 custom emoji exactly like the manual edit-save path. + * Finding the target message is the caller's job — it reuses ChannelPane's + * existing `findLastOwnEditable`, so the "what is editable" rule has one home. */ +import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; +import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; +import { + buildOutgoingMessage, + findSpoileredImetaMediaUrls, + imetaMediaFromTags, + mergeOutgoingTags, + restoreImetaMediaDisplayLabels, + stripImetaMediaLines, +} from "@/features/messages/lib/imetaMediaMarkdown"; + export type SelfCorrectionCommand = { /** Literal text to search for. Guaranteed non-empty. */ pattern: string; @@ -187,3 +205,48 @@ export function applySelfCorrection( result += text.slice(copiedUpTo); return result; } + +/** An edit derived from applying a self-correction to a target message. */ +export type SelfCorrectionEdit = { + /** Rebuilt outgoing body (imeta markdown lines re-appended). */ + content: string; + /** Merged outgoing tags (imeta + NIP-30 emoji), ready for the edit path. */ + tags: string[][]; +}; + +/** + * Apply `command` to `target`'s human-visible body and assemble the outgoing + * edit the same way the manual edit-save path does — stripping imeta markdown + * before substituting, then rebuilding content + media tags + custom-emoji + * tags. Returns `null` when the pattern is not present (nothing to correct, so + * the caller sends literally). The caller supplies `target` (its most recent + * editable message) and pre-checks that `text` parsed to a command. + */ +export function buildSelfCorrectionEdit( + target: { body: string; tags?: string[][] }, + command: SelfCorrectionCommand, + customEmoji: ReadonlyArray, +): SelfCorrectionEdit | null { + // Rebuild the editable body (imeta markdown stripped) so the substitution + // runs over what the author actually sees. + const editableImeta = restoreImetaMediaDisplayLabels( + target.body, + imetaMediaFromTags(target.tags ?? []), + ); + const editableBody = stripImetaMediaLines(target.body, editableImeta); + const correctedBody = applySelfCorrection(editableBody, command); + if (correctedBody === null || correctedBody === editableBody) { + return null; + } + + const { content, mediaTags } = buildOutgoingMessage( + correctedBody, + editableImeta, + findSpoileredImetaMediaUrls(target.body, editableImeta), + ); + const tags = + mergeOutgoingTags(mediaTags, buildCustomEmojiTags(content, customEmoji)) ?? + []; + + return { content, tags }; +} diff --git a/desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs b/desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs deleted file mode 100644 index e650ae84b5..0000000000 --- a/desktop/src/features/messages/lib/selfCorrectionEdit.test.mjs +++ /dev/null @@ -1,114 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { - findLastOwnCorrectable, - resolveSelfCorrection, -} from "./selfCorrectionEdit.ts"; - -const KIND_SYSTEM_MESSAGE = 40099; - -const msg = (overrides) => ({ - id: "id", - createdAt: 1, - pubkey: "me", - body: "", - pending: false, - tags: [], - ...overrides, -}); - -// ── findLastOwnCorrectable ────────────────────────────────────────────────── - -test("picks the author's most recent editable message", () => { - const target = findLastOwnCorrectable( - [ - msg({ id: "a", pubkey: "me", createdAt: 1 }), - msg({ id: "b", pubkey: "me", createdAt: 3 }), - msg({ id: "c", pubkey: "me", createdAt: 2 }), - ], - "me", - ); - assert.equal(target?.id, "b"); -}); - -test("ignores others' messages, system rows, and pending sends", () => { - assert.equal( - findLastOwnCorrectable( - [ - msg({ id: "other", pubkey: "you", createdAt: 9 }), - msg({ - id: "system", - pubkey: "me", - kind: KIND_SYSTEM_MESSAGE, - createdAt: 8, - }), - msg({ id: "pending", pubkey: "me", pending: true, createdAt: 7 }), - msg({ id: "mine", pubkey: "me", createdAt: 2 }), - ], - "me", - )?.id, - "mine", - ); -}); - -test("returns null when the author has no eligible message", () => { - assert.equal(findLastOwnCorrectable([msg({ pubkey: "you" })], "me"), null); -}); - -// ── resolveSelfCorrection ─────────────────────────────────────────────────── - -test("resolves a plain-text correction to the target's edit", () => { - const edit = resolveSelfCorrection( - "s/hullo/hello/", - [msg({ id: "m1", pubkey: "me", body: "hullo there" })], - "me", - [], - ); - assert.deepEqual(edit, { eventId: "m1", content: "hello there", tags: [] }); -}); - -test("returns null when the text is not a command", () => { - assert.equal( - resolveSelfCorrection( - "just a normal message", - [msg({ id: "m1", pubkey: "me", body: "hi" })], - "me", - [], - ), - null, - ); -}); - -test("returns null when there is no editable target", () => { - assert.equal( - resolveSelfCorrection("s/a/b/", [msg({ pubkey: "you" })], "me", []), - null, - ); -}); - -test("returns null when the pattern is absent from the target", () => { - assert.equal( - resolveSelfCorrection( - "s/xyz/q/", - [msg({ id: "m1", pubkey: "me", body: "hello" })], - "me", - [], - ), - null, - ); -}); - -test("re-attaches NIP-30 emoji tags for shortcodes in the corrected body", () => { - const edit = resolveSelfCorrection( - "s/hi/yo/", - [msg({ id: "m1", pubkey: "me", body: "hi :bee:" })], - "me", - [{ shortcode: "bee", url: "https://cdn/bee.png" }], - ); - assert.equal(edit?.content, "yo :bee:"); - assert.ok( - edit?.tags.some((tag) => tag[0] === "emoji" && tag[1] === "bee"), - "expected an emoji tag for :bee:", - ); -}); diff --git a/desktop/src/features/messages/lib/selfCorrectionEdit.ts b/desktop/src/features/messages/lib/selfCorrectionEdit.ts deleted file mode 100644 index d2fa009bff..0000000000 --- a/desktop/src/features/messages/lib/selfCorrectionEdit.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * Resolves an IRC-style `s/old/new/` self-correction against a channel's - * timeline into a ready-to-publish edit. - * - * This is the bridge between the pure command grammar in `selfCorrection.ts` - * and the existing kind-40003 edit path: it finds the author's most recent - * editable message, applies the substitution to its human-visible body, and - * rebuilds the outgoing edit — preserving imeta attachments and NIP-30 custom - * emoji exactly like the manual edit-save path does. Kept UI-free so it can be - * unit-tested; the composing hook only wires state and publishes the result. - * - * Mentions: a typo-fix correction intentionally emits no new mention `p` tags - * (the corrected body keeps the original mention tokens, so nobody is re-woken - * and existing mentions still render). A substitution that introduces a - * brand-new `@mention` will therefore not notify that user — an accepted v1 - * limitation, since the shortcut targets quick self-corrections. - */ - -import { buildCustomEmojiTags } from "@/shared/lib/customEmojiTags"; -import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji"; -import { KIND_SYSTEM_MESSAGE } from "@/shared/constants/kinds"; -import type { TimelineMessage } from "@/features/messages/types"; -import { - buildOutgoingMessage, - findSpoileredImetaMediaUrls, - imetaMediaFromTags, - mergeOutgoingTags, - restoreImetaMediaDisplayLabels, - stripImetaMediaLines, -} from "@/features/messages/lib/imetaMediaMarkdown"; -import { - applySelfCorrection, - parseSelfCorrection, -} from "@/features/messages/lib/selfCorrection"; - -/** A published edit derived from a self-correction command. */ -export type SelfCorrectionEdit = { - eventId: string; - content: string; - tags: string[][]; -}; - -/** - * The author's most recent message that a self-correction may target, mirroring - * the eligibility rules of ArrowUp-to-edit: own, non-system, not still pending. - */ -export function findLastOwnCorrectable( - candidates: readonly TimelineMessage[], - currentPubkey: string, -): TimelineMessage | null { - let best: TimelineMessage | null = null; - for (const message of candidates) { - if ( - message.kind === KIND_SYSTEM_MESSAGE || - message.pubkey !== currentPubkey || - message.pending - ) { - continue; - } - if (!best || message.createdAt >= best.createdAt) { - best = message; - } - } - return best; -} - -/** - * Resolve `text` as a self-correction against `candidates`. Returns the edit to - * publish, or `null` when `text` is not a command, there is no editable target, - * or the pattern does not occur in the target (all of which mean "send `text` - * literally instead"). - */ -export function resolveSelfCorrection( - text: string, - candidates: readonly TimelineMessage[], - currentPubkey: string, - customEmoji: ReadonlyArray, -): SelfCorrectionEdit | null { - const command = parseSelfCorrection(text.trim()); - if (!command) return null; - - const target = findLastOwnCorrectable(candidates, currentPubkey); - if (!target) return null; - - // Rebuild the human-editable body (imeta markdown lines stripped) so the - // substitution runs over what the author actually sees, then reassemble the - // outgoing edit the same way the manual edit-save path does. - const editableImeta = restoreImetaMediaDisplayLabels( - target.body, - imetaMediaFromTags(target.tags ?? []), - ); - const editableBody = stripImetaMediaLines(target.body, editableImeta); - const correctedBody = applySelfCorrection(editableBody, command); - if (correctedBody === null || correctedBody === editableBody) { - return null; - } - - const { content, mediaTags } = buildOutgoingMessage( - correctedBody, - editableImeta, - findSpoileredImetaMediaUrls(target.body, editableImeta), - ); - const tags = - mergeOutgoingTags(mediaTags, buildCustomEmojiTags(content, customEmoji)) ?? - []; - - return { eventId: target.id, content, tags }; -} diff --git a/desktop/src/features/messages/ui/useSelfCorrectingSend.ts b/desktop/src/features/messages/ui/useSelfCorrectingSend.ts index 2beb93a888..043065ddc7 100644 --- a/desktop/src/features/messages/ui/useSelfCorrectingSend.ts +++ b/desktop/src/features/messages/ui/useSelfCorrectingSend.ts @@ -1,57 +1,59 @@ import * as React from "react"; import { useCustomEmoji } from "@/features/custom-emoji/hooks"; -import { resolveSelfCorrection } from "@/features/messages/lib/selfCorrectionEdit"; +import { + buildSelfCorrectionEdit, + parseSelfCorrection, +} from "@/features/messages/lib/selfCorrection"; import type { TimelineMessage } from "@/features/messages/types"; -type EditSaveById = ( - eventId: string, +type EditSave = ( content: string, mediaTags?: string[][], mentionPubkeys?: string[], + eventId?: string, ) => Promise; /** - * Returns a guard for the send path: given the message a user is about to send, - * if its body is a well-formed `s/old/new/` command it edits the author's most - * recent message (via the existing kind-40003 edit path) and resolves `true`, - * telling the caller to skip the literal send. Otherwise resolves `false` and - * the caller sends normally. + * Send-path guard for IRC-style `s/old/new/` self-correction. Given the text a + * user is about to send: if it is a well-formed substitution command it edits + * the author's most recent message — via the existing edit path (`onEditSave` + * with an explicit id) — and resolves `true`, telling the caller to skip the + * literal send. Otherwise it resolves `false` and the caller sends normally. * - * Skipped when the draft carries its own attachments (then `s/a/b/` is a - * caption, not a command) or when `onEditSaveById`/`currentPubkey` are absent. + * The command grammar and the media/emoji-preserving edit assembly live in the + * pure, tested `selfCorrection` lib; this hook only wires React state. The + * target lookup is the caller's own `findLastOwnEditable`, so "what counts as + * editable" has a single definition shared with ArrowUp-to-edit. + * + * Everything is read through a ref so the returned callback is reference-stable + * and never re-renders MessageComposer (see the React.memo note in AGENTS.md). */ export function useSelfCorrectingSend(params: { - messages: readonly TimelineMessage[]; - onEditSaveById?: EditSaveById; - currentPubkey?: string; -}): (content: string, mediaTags: string[][] | undefined) => Promise { + messages: TimelineMessage[]; + findLastOwnEditable: ( + candidates: TimelineMessage[], + ) => TimelineMessage | null; + onEditSave?: EditSave; +}): (content: string, mediaTags?: string[][]) => Promise { const customEmoji = useCustomEmoji(); - const customEmojiRef = React.useRef(customEmoji); - customEmojiRef.current = customEmoji; - const messagesRef = React.useRef(params.messages); - messagesRef.current = params.messages; - const onEditSaveByIdRef = React.useRef(params.onEditSaveById); - onEditSaveByIdRef.current = params.onEditSaveById; - const currentPubkeyRef = React.useRef(params.currentPubkey); - currentPubkeyRef.current = params.currentPubkey; + const ref = React.useRef({ ...params, customEmoji }); + ref.current = { ...params, customEmoji }; return React.useCallback(async (content, mediaTags) => { - const saveById = onEditSaveByIdRef.current; - const currentPubkey = currentPubkeyRef.current; - if (!saveById || !currentPubkey) return false; - // A draft with its own attachments is an ordinary send with a caption. - if (mediaTags && mediaTags.length > 0) return false; - - const edit = resolveSelfCorrection( - content, - messagesRef.current, - currentPubkey, - customEmojiRef.current, - ); + const { messages, findLastOwnEditable, onEditSave, customEmoji } = + ref.current; + // A draft with its own attachments is an ordinary send with a caption; + // archived (edit-disabled) channels pass no `onEditSave`. + if (!onEditSave || (mediaTags && mediaTags.length > 0)) return false; + const command = parseSelfCorrection(content.trim()); + if (!command) return false; + const target = findLastOwnEditable(messages); + if (!target) return false; + const edit = buildSelfCorrectionEdit(target, command, customEmoji); if (!edit) return false; - - await saveById(edit.eventId, edit.content, edit.tags); + // No new mention `p` tags — a typo-fix re-wakes nobody. + await onEditSave(edit.content, edit.tags, undefined, target.id); return true; }, []); }