diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx index 90dd441cd..fd33ab152 100644 --- a/desktop/src/features/channels/ui/ChannelPane.tsx +++ b/desktop/src/features/channels/ui/ChannelPane.tsx @@ -4,6 +4,7 @@ import { Hash, LogIn } from "lucide-react"; import { MessageComposer } from "@/features/messages/ui/MessageComposer"; import { MessageThreadPanel } from "@/features/messages/ui/MessageThreadPanel"; import { MessageTimeline } from "@/features/messages/ui/MessageTimeline"; +import { useComposerHeightPadding } from "@/features/messages/ui/useComposerHeightPadding"; import { TypingIndicatorRow } from "@/features/messages/ui/TypingIndicatorRow"; import type { TypingIndicatorEntry } from "@/features/messages/useChannelTyping"; import { UserProfilePanel } from "@/features/profile/ui/UserProfilePanel"; @@ -175,6 +176,10 @@ export const ChannelPane = React.memo(function ChannelPane({ () => getInitialThreadPanelWidth(), ); + const timelineScrollRef = React.useRef(null); + const composerWrapperRef = React.useRef(null); + useComposerHeightPadding(timelineScrollRef, composerWrapperRef); + React.useEffect(() => { if (typeof window === "undefined") { return; @@ -319,6 +324,7 @@ export const ChannelPane = React.memo(function ChannelPane({ ) : ( -
+
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/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); +}); diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts index b6ce0897b..efcae0a36 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,43 @@ 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, + ); + } + + // 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); }, }, props: { @@ -72,7 +101,7 @@ export function buildHighlightPatterns( n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), ); patterns.push( - new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})`, "gi"), + new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})(?=\\W|$)`, "gi"), ); } @@ -84,7 +113,10 @@ export function buildHighlightPatterns( n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), ); patterns.push( - new RegExp(`(?:^|(?<=\\s))#(${escapedChannels.join("|")})`, "gi"), + new RegExp( + `(?:^|(?<=\\s))#(${escapedChannels.join("|")})(?=\\W|$)`, + "gi", + ), ); } @@ -112,6 +144,89 @@ 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; +} + +/** + * 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[], 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 - * `