From d97121c8e3c9a46d6d8c7bc7cf215601d2fb19c4 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:08:55 -0700 Subject: [PATCH 01/10] fix: block send while media is uploading Add isUploading guard to both MessageComposer and ForumComposer: - sendDisabled memo now includes media.isUploading (disables button visually) - submitMessage uses isUploadingRef to prevent Enter key bypass --- desktop/src/features/forum/ui/ForumComposer.tsx | 8 ++++++-- desktop/src/features/messages/ui/MessageComposer.tsx | 8 ++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 75d1fe7eb..d63958cff 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -66,9 +66,11 @@ export function ForumComposer({ const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); + const isUploadingRef = React.useRef(media.isUploading); const onSubmitRef = React.useRef(onSubmit); disabledRef.current = disabled; isSendingRef.current = isSending; + isUploadingRef.current = media.isUploading; onSubmitRef.current = onSubmit; const isAutocompleteOpenRef = React.useRef(false); @@ -179,7 +181,8 @@ export function ForumComposer({ if ( (!trimmed && !hasMedia) || disabledRef.current || - isSendingRef.current + isSendingRef.current || + isUploadingRef.current ) { return; } @@ -330,8 +333,9 @@ export function ForumComposer({ const sendDisabled = React.useMemo( () => disabled || + media.isUploading || (content.trim().length === 0 && media.pendingImeta.length === 0), - [disabled, content, media.pendingImeta.length], + [disabled, media.isUploading, content, media.pendingImeta.length], ); const hasComposerContent = content.trim().length > 0 || diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index d376b0909..97e0003c5 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -122,11 +122,13 @@ export function MessageComposer({ const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); + const isUploadingRef = React.useRef(media.isUploading); const onSendRef = React.useRef(onSend); const onEditSaveRef = React.useRef(onEditSave); const editTargetRef = React.useRef(editTarget); disabledRef.current = disabled; isSendingRef.current = isSending; + isUploadingRef.current = media.isUploading; onSendRef.current = onSend; onEditSaveRef.current = onEditSave; editTargetRef.current = editTarget; @@ -366,7 +368,8 @@ export function MessageComposer({ if ( (!trimmed && !hasMedia) || disabledRef.current || - isSendingRef.current + isSendingRef.current || + isUploadingRef.current ) { return; } @@ -583,8 +586,9 @@ export function MessageComposer({ const sendDisabled = React.useMemo( () => disabled || + media.isUploading || (content.trim().length === 0 && media.pendingImeta.length === 0), - [disabled, content, media.pendingImeta.length], + [disabled, media.isUploading, content, media.pendingImeta.length], ); const handleCaptureSelection = React.useCallback(() => { From f74e5110c2e0fdcd9aef798bdc26a60da1dadfd6 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:13:43 -0700 Subject: [PATCH 02/10] fix: prevent cursor jumping to end on thread re-renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split useRichTextEditor's focus into focusEnd() and focusPreserve(). focusPreserve() ensures DOM focus without moving the ProseMirror selection — used by the reply-target effect so incoming messages don't yank the caret to the end while the user is editing mid-text. focusEnd() remains the default for mount, channel switch, and entering edit mode where jumping to end is the correct behavior. --- .../messages/lib/useRichTextEditor.ts | 20 ++++++++++++++++++- .../features/messages/ui/MessageComposer.tsx | 6 ++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts index 3c0c4399c..1bb34315b 100644 --- a/desktop/src/features/messages/lib/useRichTextEditor.ts +++ b/desktop/src/features/messages/lib/useRichTextEditor.ts @@ -343,10 +343,26 @@ export function useRichTextEditor({ [editor], ); - const focus = React.useCallback(() => { + const focusEnd = React.useCallback(() => { editor?.commands.focus("end"); }, [editor]); + /** + * Ensure the editor has DOM focus without moving the ProseMirror + * selection. If the editor already has focus this is a no-op. + * Use this for re-render-triggered focus calls (e.g. reply-target + * effect) where we don't want to yank the cursor to the end. + */ + const focusPreserve = React.useCallback(() => { + if (!editor) return; + // `focus()` with no position argument preserves the current selection. + editor.commands.focus(); + }, [editor]); + + // Backwards-compatible alias — existing call sites that want "end" + // behaviour keep working. New call sites should use the explicit names. + const focus = focusEnd; + /** * Plain-text view of the document plus the cursor position in * plain-text offset space. Used by autocomplete detection (mentions, @@ -416,6 +432,8 @@ export function useRichTextEditor({ clearContent, setContent, focus, + focusEnd, + focusPreserve, getPlainTextAndCursor, replacePlainTextRange, }; diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 97e0003c5..ff0732a13 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -235,10 +235,12 @@ export function MessageComposer({ }, [editTarget?.id]); // ── Focus on reply ────────────────────────────────────────────────── + // Use focusPreserve so that re-renders (e.g. new messages arriving in + // a thread) don't yank the cursor to the end while the user is editing. React.useEffect(() => { if (!replyTarget || disabled) return; - richText.focus(); - }, [disabled, replyTarget, richText.focus]); + richText.focusPreserve(); + }, [disabled, replyTarget, richText.focusPreserve]); // ── Autofocus on mount / channel switch ───────────────────────────── useComposerAutofocus(richText.focus, effectiveDraftKey, disabled); From 654f34bae9422903918a365682c6052cc8ad6a34 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:14:53 -0700 Subject: [PATCH 03/10] perf: optimize mentionHighlightExtension decoration updates Instead of rebuilding all decorations from scratch on every keystroke, use DecorationSet.map(tr.mapping) to shift existing decoration positions for edits that don't touch mention boundaries (@ or #). Full rebuild only triggers when: - The meta key is set (names/channels list changed) - The edited text contains @ or # (mention may have been created/modified/destroyed) This eliminates unnecessary DOM churn from decoration replacement on every character typed, which could cause cursor flicker in edge cases. --- .../messages/lib/mentionHighlightExtension.ts | 77 ++++++++++++++++++- 1 file changed, 74 insertions(+), 3 deletions(-) diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index b6ce0897b..2eff4523d 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -1,5 +1,5 @@ import { Extension } from "@tiptap/core"; -import { Plugin, PluginKey } from "@tiptap/pm/state"; +import { Plugin, PluginKey, type Transaction } from "@tiptap/pm/state"; import { Decoration, DecorationSet } from "@tiptap/pm/view"; export const mentionHighlightKey = new PluginKey("mentionHighlight"); @@ -36,14 +36,33 @@ export const MentionHighlightExtension = Extension.create({ ); }, apply(tr, oldDecorations) { - if (tr.docChanged || tr.getMeta(mentionHighlightKey)) { + // Names/channels changed — full rebuild required. + if (tr.getMeta(mentionHighlightKey)) { return buildDecorations( tr.doc, extension.storage.names, extension.storage.channelNames, ); } - return oldDecorations; + + if (!tr.docChanged) { + return oldDecorations; + } + + // Check if the edit touches a mention boundary. If the changed + // ranges contain `@` or `#` (either before or after the edit), + // a mention may have been created, modified, or destroyed — do + // a full rebuild. Otherwise, just map existing decoration + // positions through the transaction mapping (cheap, no DOM churn). + if (editAffectsMentionBoundary(tr)) { + return buildDecorations( + tr.doc, + extension.storage.names, + extension.storage.channelNames, + ); + } + + return oldDecorations.map(tr.mapping, tr.doc); }, }, props: { @@ -112,6 +131,58 @@ export function findHighlightMatches( return results; } +/** + * Returns true if the transaction's changed ranges touch text that contains + * `@` or `#` — meaning a mention/channel-link boundary may have been + * created, modified, or destroyed and we need a full decoration rebuild. + * + * We check both the old content (in case a mention was deleted/split) and + * the new content (in case one was just typed). Uses a simple approach: + * iterate each step's changed ranges via the first stepMap (sufficient for + * the single-step transactions a chat composer produces on each keystroke). + */ +function editAffectsMentionBoundary(tr: Transaction): boolean { + const mentionChars = /[@#]/; + + // For each step, check old and new text in the changed range. + // stepMap.forEach gives (oldFrom, oldTo, newFrom, newTo) where old + // positions are in the doc before that step and new positions are in + // the doc after that step. + for (let i = 0; i < tr.steps.length; i++) { + const map = tr.mapping.maps[i]; + + let found = false; + map.forEach((oldFrom, oldTo, newFrom, newTo) => { + if (found) return; + + // Check new doc text in the affected range + const clampedNewTo = Math.min(newTo, tr.doc.content.size); + const clampedNewFrom = Math.min(newFrom, clampedNewTo); + if (clampedNewFrom < clampedNewTo) { + const newText = tr.doc.textBetween(clampedNewFrom, clampedNewTo, "\n", "\0"); + if (mentionChars.test(newText)) { + found = true; + return; + } + } + + // Check old doc text in the affected range + const clampedOldTo = Math.min(oldTo, tr.before.content.size); + const clampedOldFrom = Math.min(oldFrom, clampedOldTo); + if (clampedOldFrom < clampedOldTo) { + const oldText = tr.before.textBetween(clampedOldFrom, clampedOldTo, "\n", "\0"); + if (mentionChars.test(oldText)) { + found = true; + } + } + }); + + if (found) return true; + } + + return false; +} + function buildDecorations( doc: Parameters[0], names: string[], From 68c79489b6ee46a151852de21db5e3373e4485b5 Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Wed, 20 May 2026 22:45:14 -0700 Subject: [PATCH 04/10] fix: preserve rich formatting when pasting content with mentions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, pasting any content that contained mention/channel-link elements would flatten the ENTIRE paste to plain text, stripping all formatting (bold, italic, line breaks). This was because normalizeMentionClipboardHtml returned doc.body.textContent. Now it returns cleaned HTML (with mention wrappers replaced by plain spans and font-weight:600 styles stripped) and the paste handler uses Tiptap's insertContent to parse the HTML — preserving all surrounding rich formatting while still preventing mention font-weight from being misinterpreted as bold. --- .../src/features/forum/ui/ForumComposer.tsx | 16 ++++----- .../messages/lib/normalizeMentionClipboard.ts | 36 ++++++++++++++----- .../features/messages/ui/MessageComposer.tsx | 27 +++++++------- 3 files changed, 48 insertions(+), 31 deletions(-) diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index d63958cff..88c9a7fda 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -312,15 +312,15 @@ export function ForumComposer({ const html = event.clipboardData?.getData("text/html"); if (html && hasMentionClipboardHtml(html)) { - const cleanText = normalizeMentionClipboardHtml(html); + const cleanHtml = normalizeMentionClipboardHtml(html); event.preventDefault(); - _view.dispatch( - _view.state.tr.insertText( - cleanText, - _view.state.selection.from, - _view.state.selection.to, - ), - ); + richText.editor + ?.chain() + .focus() + .insertContent(cleanHtml, { + parseOptions: { preserveWhitespace: "full" }, + }) + .run(); return true; } diff --git a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts index a4c57dbd1..c9d2fd73a 100644 --- a/desktop/src/features/messages/lib/normalizeMentionClipboard.ts +++ b/desktop/src/features/messages/lib/normalizeMentionClipboard.ts @@ -9,12 +9,12 @@ export function hasMentionClipboardHtml(html: string): boolean { /** * Normalize clipboard HTML that contains Sprout mention / channel-link * elements. Replaces the styled `` and - * ` ) : ( -
+
) : null} -
+
(null); + // biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref React.useEffect(() => { if ( !searchActiveMessageId || @@ -139,10 +140,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({ onScroll={syncScrollState} ref={scrollContainerRef} > -
+
{isFetchingOlder ? ( diff --git a/desktop/src/features/messages/ui/useComposerHeightPadding.ts b/desktop/src/features/messages/ui/useComposerHeightPadding.ts index f9bcfe667..c20db3a02 100644 --- a/desktop/src/features/messages/ui/useComposerHeightPadding.ts +++ b/desktop/src/features/messages/ui/useComposerHeightPadding.ts @@ -29,7 +29,8 @@ export function useComposerHeightPadding( }; const observer = new ResizeObserver(([entry]) => { - const height = entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; + const height = + entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height; // Add a small buffer (8px) so the last message isn't flush against the composer const padding = Math.ceil(height) + 8; const wasAtBottom = isNearBottom(); diff --git a/desktop/src/shared/lib/mentionPattern.ts b/desktop/src/shared/lib/mentionPattern.ts index ea93a5b12..d0c636836 100644 --- a/desktop/src/shared/lib/mentionPattern.ts +++ b/desktop/src/shared/lib/mentionPattern.ts @@ -7,7 +7,10 @@ export function escapeRegExp(str: string): string { /** * Build a regex that matches a given prefix followed by known multi-word names - * (longest-first to avoid partial matches), then falling back to prefix + \S+. + * (longest-first to avoid partial matches). When known names are provided, + * only those names are matched — no generic fallback. When no names are + * available, falls back to prefix + \S+ for backwards compatibility (e.g. + * old messages without proper p-tags, or while profiles are loading). */ export function buildPrefixPattern( prefix: string, @@ -25,10 +28,7 @@ export function buildPrefixPattern( const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|"); const boundary = "(?=[\\s,;.!?:)\\]}]|$)"; - return new RegExp( - `${escapedPrefix}(?:(?:${nameAlternatives})${boundary}|\\S+)`, - "gi", - ); + return new RegExp(`${escapedPrefix}(?:${nameAlternatives})${boundary}`, "gi"); } /** From 278aa8df7fd8b916e5c665d639a0450bca23259f Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 14:17:04 -0700 Subject: [PATCH 09/10] fix: rebuild decorations when edit intersects existing mention highlight MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edits inside an already-highlighted mention (e.g. @Max → @Marx) didn't contain @ or #, so editAffectsMentionBoundary returned false and the old decoration was just mapped — leaving a stale highlight. Add editIntersectsDecoration check: if any changed range overlaps an existing decoration, trigger a full rebuild. The fast-path (edits that don't touch mentions at all) is preserved. --- .../messages/lib/mentionHighlightExtension.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index 7f39d59d9..efcae0a36 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.ts +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts @@ -62,6 +62,16 @@ export const MentionHighlightExtension = Extension.create({ ); } + // If an edit intersects an existing decoration, the mapped + // decoration may become stale (e.g. @Max → @Marx). Rebuild. + if (editIntersectsDecoration(tr, oldDecorations)) { + return buildDecorations( + tr.doc, + extension.storage.names, + extension.storage.channelNames, + ); + } + return oldDecorations.map(tr.mapping, tr.doc); }, }, @@ -91,7 +101,7 @@ export function buildHighlightPatterns( n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), ); patterns.push( - new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})`, "gi"), + new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})(?=\\W|$)`, "gi"), ); } @@ -103,7 +113,10 @@ export function buildHighlightPatterns( n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), ); patterns.push( - new RegExp(`(?:^|(?<=\\s))#(${escapedChannels.join("|")})`, "gi"), + new RegExp( + `(?:^|(?<=\\s))#(${escapedChannels.join("|")})(?=\\W|$)`, + "gi", + ), ); } @@ -193,6 +206,27 @@ function editAffectsMentionBoundary(tr: Transaction): boolean { return false; } +/** + * Returns true if any changed range in the transaction overlaps an existing + * mention decoration. In that case the mapped decoration would be stale + * (e.g. @Max edited to @Marx) and we need a full rebuild. + */ +function editIntersectsDecoration( + tr: Transaction, + decorations: DecorationSet, +): boolean { + let hit = false; + tr.mapping.maps.forEach((map) => { + map.forEach((oldFrom, oldTo) => { + if (hit) return; + if (decorations.find(oldFrom, oldTo).length > 0) { + hit = true; + } + }); + }); + return hit; +} + function buildDecorations( doc: Parameters[0], names: string[], From ea5bf052af73823899f930315461b5c2d2f466ac Mon Sep 17 00:00:00 2001 From: Taylor Ho Date: Thu, 21 May 2026 15:41:09 -0700 Subject: [PATCH 10/10] test(mentions): add trailing word boundary regression tests Assert @Marge does NOT match inside @Margex and #general does NOT match inside #generally. Protects the (?=\W|$) lookahead fix. --- .../lib/mentionHighlightExtension.test.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs index 9adec5446..0931a7324 100644 --- a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs +++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs @@ -138,3 +138,17 @@ test("handles empty patterns against non-empty text", () => { const matches = findHighlightMatches("@alice #general", []); assert.equal(matches.length, 0); }); + +// ── Trailing word boundary regression tests ─────────────────────────── + +test("@Marge should NOT match inside @Margex (trailing word boundary)", () => { + const patterns = buildHighlightPatterns(["Marge"], []); + const matches = findHighlightMatches("@Margex", patterns); + assert.equal(matches.length, 0); +}); + +test("#general should NOT match inside #generally (trailing word boundary)", () => { + const patterns = buildHighlightPatterns([], ["general"]); + const matches = findHighlightMatches("#generally", patterns); + assert.equal(matches.length, 0); +});