mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix: composer UX improvements (cursor, upload guard, scroll, paste, perf) (#694)
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user