diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 70877875f7..d405fba532 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, @@ -360,6 +361,12 @@ export const ChannelPane = React.memo(function ChannelPane({ clearWelcomeComposerDismissTimer, isActiveWelcomeChannel, ]); + // `s/old/new/` self-correction — reuses findLastOwnEditable + onEditSave. + const maybeSelfCorrect = useSelfCorrectingSend({ + messages, + findLastOwnEditable, + onEditSave, + }); 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 maybeSelfCorrect(content, mediaTags)) return; messageTimelineRef.current?.scrollToBottomOnNextUpdate(); await onSendMessage(content, mentionPubkeys, mediaTags, channelId); @@ -394,6 +403,7 @@ export const ChannelPane = React.memo(function ChannelPane({ goChannel, isActiveWelcomeChannel, knownAgentPubkeys, + maybeSelfCorrect, onSendMessage, ], ); diff --git a/desktop/src/features/channels/ui/ChannelPane.types.ts b/desktop/src/features/channels/ui/ChannelPane.types.ts index 7257d8cd55..87f992f728 100644 --- a/desktop/src/features/channels/ui/ChannelPane.types.ts +++ b/desktop/src/features/channels/ui/ChannelPane.types.ts @@ -87,6 +87,9 @@ export type ChannelPaneProps = { 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/useChannelPaneHandlers.ts b/desktop/src/features/channels/useChannelPaneHandlers.ts index 8c5f54fffc..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, 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..086be0453b --- /dev/null +++ b/desktop/src/features/messages/lib/selfCorrection.test.mjs @@ -0,0 +1,263 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + applySelfCorrection, + buildSelfCorrectionEdit, + 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("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); + } +}); + +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", // second char isn't the slash delimiter → not a command + "she said s/x/y/", // does not start with the command + "hello world", + "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 + ]) { + 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("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); + 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"); +}); + +// ── 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 new file mode 100644 index 0000000000..e8fe6ce484 --- /dev/null +++ b/desktop/src/features/messages/lib/selfCorrection.ts @@ -0,0 +1,252 @@ +/** + * 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, 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. + * - 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. + * + * `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; + /** 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; +}; + +/** The one supported delimiter — the canonical IRC/sed `s/old/new/` slash. */ +const DELIMITER = "/"; + +/** + * 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/` + 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) { + 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; +} + +/** 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/ui/useSelfCorrectingSend.ts b/desktop/src/features/messages/ui/useSelfCorrectingSend.ts new file mode 100644 index 0000000000..043065ddc7 --- /dev/null +++ b/desktop/src/features/messages/ui/useSelfCorrectingSend.ts @@ -0,0 +1,59 @@ +import * as React from "react"; + +import { useCustomEmoji } from "@/features/custom-emoji/hooks"; +import { + buildSelfCorrectionEdit, + parseSelfCorrection, +} from "@/features/messages/lib/selfCorrection"; +import type { TimelineMessage } from "@/features/messages/types"; + +type EditSave = ( + content: string, + mediaTags?: string[][], + mentionPubkeys?: string[], + eventId?: string, +) => Promise; + +/** + * 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. + * + * 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: TimelineMessage[]; + findLastOwnEditable: ( + candidates: TimelineMessage[], + ) => TimelineMessage | null; + onEditSave?: EditSave; +}): (content: string, mediaTags?: string[][]) => Promise { + const customEmoji = useCustomEmoji(); + const ref = React.useRef({ ...params, customEmoji }); + ref.current = { ...params, customEmoji }; + + return React.useCallback(async (content, mediaTags) => { + 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; + // No new mention `p` tags — a typo-fix re-wakes nobody. + await onEditSave(edit.content, edit.tags, undefined, target.id); + return true; + }, []); +}