Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion desktop/src/features/channels/ui/ChannelPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -175,6 +176,10 @@ export const ChannelPane = React.memo(function ChannelPane({
() => getInitialThreadPanelWidth(),
);

const timelineScrollRef = React.useRef<HTMLDivElement>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
useComposerHeightPadding(timelineScrollRef, composerWrapperRef);

React.useEffect(() => {
if (typeof window === "undefined") {
return;
Expand Down Expand Up @@ -319,6 +324,7 @@ export const ChannelPane = React.memo(function ChannelPane({
<MessageTimeline
channelId={activeChannel?.id}
activeReplyTargetId={openThreadHeadId}
scrollContainerRef={timelineScrollRef}
currentPubkey={currentPubkey}
fetchOlder={fetchOlder}
hasOlderMessages={hasOlderMessages}
Expand Down Expand Up @@ -377,7 +383,10 @@ export const ChannelPane = React.memo(function ChannelPane({
</Button>
</div>
) : (
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10">
<div
className="pointer-events-none absolute inset-x-0 bottom-0 z-10"
ref={composerWrapperRef}
>
<div className="pointer-events-auto">
<MessageComposer
channelId={activeChannel?.id ?? null}
Expand Down
18 changes: 8 additions & 10 deletions desktop/src/features/forum/ui/ForumComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -179,7 +181,8 @@ export function ForumComposer({
if (
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current
isSendingRef.current ||
isUploadingRef.current
) {
return;
}
Expand Down Expand Up @@ -309,15 +312,9 @@ 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,
),
);
_view.pasteHTML(cleanHtml);
return true;
}

Expand All @@ -330,8 +327,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 ||
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
125 changes: 120 additions & 5 deletions desktop/src/features/messages/lib/mentionHighlightExtension.ts
Original file line number Diff line number Diff line change
@@ -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");
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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"),
);
}

Expand All @@ -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",
),
);
}

Expand Down Expand Up @@ -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<typeof DecorationSet.create>[0],
names: string[],
Expand Down
36 changes: 28 additions & 8 deletions desktop/src/features/messages/lib/normalizeMentionClipboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,42 @@ export function hasMentionClipboardHtml(html: string): boolean {
/**
* Normalize clipboard HTML that contains Sprout mention / channel-link
* elements. Replaces the styled `<span data-mention>` and
* `<button data-channel-link>` wrappers with their plain text content so
* the resulting string is free of formatting that would confuse TipTap's
* Bold extension (which matches font-weight >= 500 as bold).
* `<button data-channel-link>` wrappers with unstyled text nodes so
* TipTap's Bold extension doesn't misinterpret their font-weight as bold.
*
* Returns the flattened plain-text string ready for insertion into the
* editor.
* Returns cleaned HTML string that preserves surrounding formatting
* (bold, italic, line breaks, etc.) while stripping only the mention/
* channel-link styling.
*/
export function normalizeMentionClipboardHtml(html: string): string {
const doc = new DOMParser().parseFromString(html, "text/html");

for (const el of Array.from(
doc.querySelectorAll("[data-mention], [data-channel-link]"),
)) {
const text = doc.createTextNode(el.textContent ?? "");
el.replaceWith(text);
// Replace the styled wrapper with a plain <span> containing the text.
// This preserves the text content inline while stripping the
// font-weight/color styles that would confuse Tiptap's mark detection.
const span = doc.createElement("span");
span.textContent = el.textContent ?? "";
el.replaceWith(span);
}

return doc.body.textContent ?? "";
// Also strip any inline font-weight styles on remaining elements that
// could be misinterpreted as bold by Tiptap (font-weight >= 500).
for (const el of Array.from(doc.querySelectorAll("[style]"))) {
if (el instanceof HTMLElement) {
const fw = el.style.fontWeight;
// Remove font-weight if it's the mention-highlight value (600)
// but not an intentional bold (700/bold).
if (fw === "600") {
el.style.removeProperty("font-weight");
if (!el.getAttribute("style")?.trim()) {
el.removeAttribute("style");
}
}
}
}

return doc.body.innerHTML;
}
20 changes: 19 additions & 1 deletion desktop/src/features/messages/lib/useRichTextEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -416,6 +432,8 @@ export function useRichTextEditor({
clearContent,
setContent,
focus,
focusEnd,
focusPreserve,
getPlainTextAndCursor,
replacePlainTextRange,
};
Expand Down
Loading
Loading