fix: composer UX improvements (cursor, upload guard, scroll, paste, perf) (#694)

This commit is contained in:
Taylor Ho
2026-05-21 22:53:10 +00:00
committed by GitHub
parent cfd5a82c7b
commit ee4ee5f850
11 changed files with 289 additions and 59 deletions
@@ -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<HTMLDivElement>(null);
const composerWrapperRef = React.useRef<HTMLDivElement>(null);
useComposerHeightPadding(timelineScrollRef, composerWrapperRef);
React.useEffect(() => {
if (typeof window === "undefined") {
return;
@@ -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}
@@ -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}
@@ -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;
}
@@ -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;
}
@@ -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 ||
@@ -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);
});
@@ -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<typeof DecorationSet.create>[0],
names: string[],
@@ -9,12 +9,12 @@ 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");
@@ -22,9 +22,29 @@ export function normalizeMentionClipboardHtml(html: string): string {
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;
}
@@ -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,
};
@@ -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;
@@ -233,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);
@@ -366,7 +370,8 @@ export function MessageComposer({
if (
(!trimmed && !hasMedia) ||
disabledRef.current ||
isSendingRef.current
isSendingRef.current ||
isUploadingRef.current
) {
return;
}
@@ -544,27 +549,15 @@ export function MessageComposer({
// --- Mention / channel-link normalization ---
// When copying from the chat area the browser puts styled HTML
// on the clipboard. TipTap's DOMParser doesn't understand our
// custom `data-mention` / `data-channel-link` spans, so the
// pasted text can arrive with stale formatting and without the
// `@` / `#` prefix. Detect this case, flatten the HTML to
// plain text and insert directly — bypassing TipTap's Bold
// extension which would otherwise wrap the mention in `**`.
// NOTE: This flattens *all* formatting in the pasted fragment
// when mentions are present. Acceptable for the primary use
// case (pasting a mention chip); a future refinement could
// preserve non-mention formatting.
// on the clipboard. The mention/channel-link wrappers have
// font-weight:600 which Tiptap's Bold extension misinterprets
// as bold. Strip those wrappers and use ProseMirror's pasteHTML
// to parse the cleaned HTML into proper rich content nodes.
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;
}
@@ -583,8 +576,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(() => {
@@ -18,6 +18,7 @@ import { MessageComposer } from "./MessageComposer";
import { MessageRow } from "./MessageRow";
import { MessageThreadSummaryRow } from "./MessageThreadSummaryRow";
import { TypingIndicatorRow } from "./TypingIndicatorRow";
import { useComposerHeightPadding } from "./useComposerHeightPadding";
import { useTimelineScrollManager } from "./useTimelineScrollManager";
type MessageThreadPanelProps = {
@@ -107,8 +108,10 @@ export function MessageThreadPanel({
widthPx,
}: MessageThreadPanelProps) {
const threadBodyRef = React.useRef<HTMLDivElement>(null);
const threadComposerWrapperRef = React.useRef<HTMLDivElement>(null);
const isOverlay = useIsThreadPanelOverlay();
useEscapeKey(onClose, isOverlay);
useComposerHeightPadding(threadBodyRef, threadComposerWrapperRef);
const threadHeadId = threadHead?.id ?? null;
@@ -198,7 +201,7 @@ export function MessageThreadPanel({
onScroll={syncScrollState}
ref={threadBodyRef}
>
<div className="pb-10" ref={contentRef}>
<div ref={contentRef}>
<div className="px-3 pb-1 pt-0" data-testid="message-thread-head">
<div className="rounded-2xl">
<MessageRow
@@ -302,7 +305,10 @@ export function MessageThreadPanel({
</div>
) : null}
<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={threadComposerWrapperRef}
>
<div className="pointer-events-auto">
<MessageComposer
channelId={channelId}
@@ -22,6 +22,9 @@ type MessageTimelineProps = {
currentPubkey?: string;
fetchOlder?: () => Promise<void>;
hasOlderMessages?: boolean;
/** Optional external ref to the scroll container — used by the parent to
* observe scroll position or adjust padding dynamically. */
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
isFetchingOlder?: boolean;
messageFooters?: Record<string, React.ReactNode>;
/** Map from lowercase pubkey → persona display name for bot members. */
@@ -65,13 +68,15 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onMarkUnread,
onReply,
onToggleReaction,
scrollContainerRef: externalScrollRef,
searchActiveMessageId = null,
searchMatchingMessageIds,
searchQuery,
targetMessageId = null,
onTargetReached,
}: MessageTimelineProps) {
const scrollContainerRef = React.useRef<HTMLDivElement>(null);
const internalScrollRef = React.useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const topSentinelRef = React.useRef<HTMLDivElement>(null);
const {
@@ -94,6 +99,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
// Scroll to the active search match when it changes.
const prevSearchActiveRef = React.useRef<string | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: scrollContainerRef is a stable React ref
React.useEffect(() => {
if (
!searchActiveMessageId ||
@@ -134,10 +140,7 @@ export const MessageTimeline = React.memo(function MessageTimeline({
onScroll={syncScrollState}
ref={scrollContainerRef}
>
<div
className="flex w-full flex-col gap-2 pb-10 pt-12"
ref={contentRef}
>
<div className="flex w-full flex-col gap-2 pt-12" ref={contentRef}>
<div ref={topSentinelRef} aria-hidden className="h-px" />
{isFetchingOlder ? (
@@ -0,0 +1,53 @@
import * as React from "react";
/**
* Observes the height of the composer overlay and sets the scroll
* container's `paddingBottom` to match, so content is never hidden
* behind the absolutely-positioned composer.
*
* If the user is already scrolled to the bottom when padding increases,
* auto-scrolls to keep them at the bottom (no visible gap).
*/
export function useComposerHeightPadding(
scrollContainerRef: React.RefObject<HTMLElement | null>,
composerRef: React.RefObject<HTMLElement | null>,
) {
React.useEffect(() => {
const scrollEl = scrollContainerRef.current;
const composerEl = composerRef.current;
if (!scrollEl || !composerEl || typeof ResizeObserver === "undefined") {
return;
}
const isNearBottom = (): boolean => {
const threshold = 32;
return (
scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight <
threshold
);
};
const observer = new ResizeObserver(([entry]) => {
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();
scrollEl.style.paddingBottom = `${padding}px`;
if (wasAtBottom) {
scrollEl.scrollTop = scrollEl.scrollHeight;
}
});
observer.observe(composerEl);
return () => {
observer.disconnect();
// Reset to a sensible default when unmounting
scrollEl.style.paddingBottom = "";
};
}, [scrollContainerRef, composerRef]);
}
+5 -5
View File
@@ -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");
}
/**