mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
perf(desktop): stop typing from re-rendering the channel pane + timeline (#1364)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -412,6 +412,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803).
|
||||
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
|
||||
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
|
||||
6. **Desktop Tauri fmt fails in worktrees and blocks commits** — the pre-commit hook runs `just desktop-tauri-fmt`, which fails in git worktrees because `cargo fmt` resolves workspace paths relative to the worktree root. Run `just desktop-tauri-fmt` from the main checkout to apply the fix, then re-stage and commit. CI is unaffected.
|
||||
7. **React render perf: `React.memo` is all-or-nothing** — it only skips a re-render when *every* prop is reference-stable; one unstable prop (inline arrow/JSX, or a hook returning a fresh `{}`/`[]`/`Map` each render) defeats it. Two repeat offenders: (a) React Query results (`useMutation`/`useQuery`) are a **new object each render** — depend on the stable method (`mutation.mutateAsync`), not the object; (b) derived `Map`/array state that recomputes on a version bump — wrap in a content-equality ref cache (`shared/hooks/useStableReference.ts`). When chasing interaction lag, **measure with DevTools closed and no perf probes** (an open Web Inspector + per-keystroke `console.log` inflate the numbers), and isolate by removing one suspect at a time rather than guessing.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -528,6 +528,7 @@ export function ChannelScreen({
|
||||
(message: TimelineMessage) => handleMarkMessageRead(message.id),
|
||||
[handleMarkMessageRead],
|
||||
);
|
||||
const sendMessageMutateAsync = sendMessageMutation.mutateAsync;
|
||||
const handleSendVideoReviewComment = React.useCallback(
|
||||
async (
|
||||
message: { id: string },
|
||||
@@ -536,19 +537,32 @@ export function ChannelScreen({
|
||||
mediaTags?: string[][],
|
||||
parentEventId?: string,
|
||||
) => {
|
||||
await sendMessageMutation.mutateAsync({
|
||||
await sendMessageMutateAsync({
|
||||
content,
|
||||
mediaTags,
|
||||
mentionPubkeys,
|
||||
parentEventId: parentEventId ?? message.id,
|
||||
});
|
||||
},
|
||||
[sendMessageMutation],
|
||||
[sendMessageMutateAsync],
|
||||
);
|
||||
const effectiveSendVideoReviewComment =
|
||||
activeChannel && !activeChannel.archivedAt && activeChannel.isMember
|
||||
? handleSendVideoReviewComment
|
||||
: undefined;
|
||||
const handleOpenAddBot = React.useCallback(() => setIsAddBotOpen(true), []);
|
||||
const handleOpenMembersSidebar = React.useCallback(
|
||||
() => setIsMembersSidebarOpen(true),
|
||||
[],
|
||||
);
|
||||
const handleCloseChannelManagement = React.useCallback(
|
||||
() => setChannelManagementOpen(false),
|
||||
[setChannelManagementOpen],
|
||||
);
|
||||
const handleChannelManagementDeleted = React.useCallback(() => {
|
||||
setChannelManagementOpen(false);
|
||||
void goHome({ replace: true });
|
||||
}, [setChannelManagementOpen, goHome]);
|
||||
const {
|
||||
agentSessionAgents,
|
||||
channelAgentSessionAgents,
|
||||
@@ -727,44 +741,77 @@ export function ChannelScreen({
|
||||
enabled: !isSinglePanelView,
|
||||
});
|
||||
|
||||
const channelHeader = (
|
||||
<ChannelScreenHeader
|
||||
activeChannel={activeChannel}
|
||||
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
||||
activeChannelTitle={activeChannelTitle}
|
||||
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
|
||||
activeDmAvatarUrl={activeDmAvatarUrl}
|
||||
activeDmHeaderParticipants={activeDmHeaderParticipants}
|
||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||
chromeWrapperRef={channelHeaderChromeRef}
|
||||
currentPubkey={currentPubkey}
|
||||
isAddBotOpen={isAddBotOpen}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onAddBotOpenChange={setIsAddBotOpen}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
onManageChannel={() => {
|
||||
if (activeChannel?.channelType === "forum") {
|
||||
openGlobalChannelManagement();
|
||||
return;
|
||||
}
|
||||
const handleManageChannel = React.useCallback(() => {
|
||||
if (activeChannel?.channelType === "forum") {
|
||||
openGlobalChannelManagement();
|
||||
return;
|
||||
}
|
||||
|
||||
if (channelManagementOpen) {
|
||||
setChannelManagementOpen(false);
|
||||
return;
|
||||
}
|
||||
if (channelManagementOpen) {
|
||||
setChannelManagementOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setOpenThreadHeadId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
setThreadScrollTargetId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
handleCloseAgentSession();
|
||||
setProfilePanelPubkey(null);
|
||||
setChannelManagementOpen(true);
|
||||
}}
|
||||
onToggleMembers={() => setIsMembersSidebarOpen((prev) => !prev)}
|
||||
showHeaderContent={!isSinglePanelView}
|
||||
transparentChrome={activeChannel?.channelType !== "forum"}
|
||||
/>
|
||||
setOpenThreadHeadId(null);
|
||||
setExpandedThreadReplyIds(new Set());
|
||||
setThreadScrollTargetId(null);
|
||||
setThreadReplyTargetId(null);
|
||||
handleCloseAgentSession();
|
||||
setProfilePanelPubkey(null);
|
||||
setChannelManagementOpen(true);
|
||||
}, [
|
||||
activeChannel?.channelType,
|
||||
channelManagementOpen,
|
||||
openGlobalChannelManagement,
|
||||
setChannelManagementOpen,
|
||||
setOpenThreadHeadId,
|
||||
handleCloseAgentSession,
|
||||
setProfilePanelPubkey,
|
||||
]);
|
||||
const handleToggleMembers = React.useCallback(
|
||||
() => setIsMembersSidebarOpen((prev) => !prev),
|
||||
[],
|
||||
);
|
||||
|
||||
const channelHeader = React.useMemo(
|
||||
() => (
|
||||
<ChannelScreenHeader
|
||||
activeChannel={activeChannel}
|
||||
activeChannelEphemeralDisplay={activeChannelEphemeralDisplay}
|
||||
activeChannelTitle={activeChannelTitle}
|
||||
actionsVariant={shouldCompactHeaderActions ? "compact" : "inline"}
|
||||
activeDmAvatarUrl={activeDmAvatarUrl}
|
||||
activeDmHeaderParticipants={activeDmHeaderParticipants}
|
||||
activeDmPresenceStatus={activeDmPresenceStatus}
|
||||
chromeWrapperRef={channelHeaderChromeRef}
|
||||
currentPubkey={currentPubkey}
|
||||
isAddBotOpen={isAddBotOpen}
|
||||
isJoining={joinChannelMutation.isPending}
|
||||
onAddBotOpenChange={setIsAddBotOpen}
|
||||
onJoinChannel={joinChannelMutation.mutateAsync}
|
||||
onManageChannel={handleManageChannel}
|
||||
onToggleMembers={handleToggleMembers}
|
||||
showHeaderContent={!isSinglePanelView}
|
||||
transparentChrome={activeChannel?.channelType !== "forum"}
|
||||
/>
|
||||
),
|
||||
[
|
||||
activeChannel,
|
||||
activeChannelEphemeralDisplay,
|
||||
activeChannelTitle,
|
||||
shouldCompactHeaderActions,
|
||||
activeDmAvatarUrl,
|
||||
activeDmHeaderParticipants,
|
||||
activeDmPresenceStatus,
|
||||
channelHeaderChromeRef,
|
||||
currentPubkey,
|
||||
isAddBotOpen,
|
||||
joinChannelMutation.isPending,
|
||||
joinChannelMutation.mutateAsync,
|
||||
handleManageChannel,
|
||||
handleToggleMembers,
|
||||
isSinglePanelView,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -807,9 +854,9 @@ export function ChannelScreen({
|
||||
fetchOlder={fetchOlder}
|
||||
header={channelHeader}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
onAddAgent={() => setIsAddBotOpen(true)}
|
||||
onAddAgent={handleOpenAddBot}
|
||||
onCreateChannel={openCreateChannel}
|
||||
onOpenMembers={() => setIsMembersSidebarOpen(true)}
|
||||
onOpenMembers={handleOpenMembersSidebar}
|
||||
isFetchingOlder={isFetchingOlder}
|
||||
editTarget={
|
||||
editTargetMessage
|
||||
@@ -834,10 +881,7 @@ export function ChannelScreen({
|
||||
messages={timelineMessages}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
onCancelThreadReply={handleCancelThreadReply}
|
||||
onChannelManagementDeleted={() => {
|
||||
setChannelManagementOpen(false);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onChannelManagementDeleted={handleChannelManagementDeleted}
|
||||
onFollowThread={
|
||||
effectiveOpenThreadHeadId != null &&
|
||||
!isNotifiedForEffectiveThread
|
||||
@@ -851,9 +895,7 @@ export function ChannelScreen({
|
||||
: undefined
|
||||
}
|
||||
onCloseAgentSession={handleCloseAgentSession}
|
||||
onCloseChannelManagement={() =>
|
||||
setChannelManagementOpen(false)
|
||||
}
|
||||
onCloseChannelManagement={handleCloseChannelManagement}
|
||||
onCloseThread={handleCloseThread}
|
||||
onDelete={
|
||||
activeChannel?.archivedAt ? undefined : handleDelete
|
||||
|
||||
@@ -50,12 +50,13 @@ export function useChannelProfilePanel({
|
||||
setProfilePanelPubkey(null);
|
||||
}, [setProfilePanelPubkey]);
|
||||
|
||||
const openDmMutateAsync = openDmMutation.mutateAsync;
|
||||
const handleOpenDm = React.useCallback(
|
||||
async (pubkeys: string[]) => {
|
||||
const dm = await openDmMutation.mutateAsync({ pubkeys });
|
||||
const dm = await openDmMutateAsync({ pubkeys });
|
||||
await goChannel(dm.id);
|
||||
},
|
||||
[goChannel, openDmMutation],
|
||||
[goChannel, openDmMutateAsync],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
} from "@/features/channels/lib/subtreeCreatedAt";
|
||||
import { computeThreadReplyUnreadCounts } from "@/features/channels/lib/threadReplyUnreadCounts";
|
||||
import { computeThreadBadgeCounts } from "@/features/channels/lib/threadBadgeCounts";
|
||||
import {
|
||||
useStableArrayShallow,
|
||||
useStableMap,
|
||||
} from "@/shared/hooks/useStableReference";
|
||||
import {
|
||||
buildThreadPanelDataFromIndex,
|
||||
buildThreadPanelIndex,
|
||||
@@ -174,7 +178,7 @@ export function useChannelUnreadState({
|
||||
],
|
||||
);
|
||||
const openThreadHeadMessage = threadPanelData.threadHead;
|
||||
const threadMessages = threadPanelData.visibleReplies;
|
||||
const threadMessages = useStableArrayShallow(threadPanelData.visibleReplies);
|
||||
const threadReplyTargetMessage = threadPanelData.replyTargetMessage;
|
||||
|
||||
// Oldest unread top-level message + count from the open-time frontier.
|
||||
@@ -332,7 +336,7 @@ export function useChannelUnreadState({
|
||||
// (LP4 Issue 2 by construction). readStateVersion is an intentional recompute
|
||||
// trigger so the badge re-reads after any marker advances.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: readStateVersion and forcedUnreadVersion are intentional recompute triggers
|
||||
const threadUnreadCounts = React.useMemo(
|
||||
const threadUnreadCountsRaw = React.useMemo(
|
||||
() =>
|
||||
computeThreadBadgeCounts(
|
||||
timelineMessages,
|
||||
@@ -353,6 +357,11 @@ export function useChannelUnreadState({
|
||||
forcedUnreadVersion,
|
||||
],
|
||||
);
|
||||
// Keep the same Map reference across recomputes when the entries are
|
||||
// unchanged. readStateVersion bumps on read-state activity (frequent in busy
|
||||
// channels) and recomputes a fresh Map even when no count changed; without
|
||||
// this, every bump hands the timeline a new prop ref and reconciles all rows.
|
||||
const threadUnreadCounts = useStableMap(threadUnreadCountsRaw);
|
||||
|
||||
// Per-message unread predicate for the mark-read/unread menu toggle. Reuses
|
||||
// computeThreadUnreadMarker — the exact function the badge counts call
|
||||
|
||||
@@ -24,5 +24,20 @@ export function useEphemeralChannelDisplay(channel: Channel | null) {
|
||||
};
|
||||
}, [deadlineKey]);
|
||||
|
||||
return channel ? getEphemeralChannelDisplay(channel, Date.now()) : null;
|
||||
const display = channel
|
||||
? getEphemeralChannelDisplay(channel, Date.now())
|
||||
: null;
|
||||
// Stabilise the reference across renders when the rendered content is
|
||||
// unchanged: this object is recomputed from Date.now() every render, and it
|
||||
// feeds memoised header/timeline subtrees — a fresh ref each render would
|
||||
// defeat their React.memo on every background re-render.
|
||||
const stableRef = React.useRef(display);
|
||||
const prev = stableRef.current;
|
||||
if (
|
||||
prev?.detailLabel !== display?.detailLabel ||
|
||||
prev?.tooltipLabel !== display?.tooltipLabel
|
||||
) {
|
||||
stableRef.current = display;
|
||||
}
|
||||
return stableRef.current;
|
||||
}
|
||||
|
||||
@@ -99,11 +99,11 @@ export function ForumComposer({
|
||||
isAutocompleteOpen: isAutocompleteOpenRef,
|
||||
onEditLink: (info) => onEditLinkRef.current?.(info),
|
||||
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
|
||||
onUpdate: ({ markdown, text }) => {
|
||||
onUpdate: ({ cursor, text }) => {
|
||||
const markdown = richText.getMarkdown();
|
||||
setContent(markdown);
|
||||
contentRef.current = markdown;
|
||||
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
mentions.updateMentionQuery(text, cursor);
|
||||
channelLinks.updateChannelQuery(text, cursor);
|
||||
},
|
||||
|
||||
@@ -530,26 +530,46 @@ export function useMediaUpload() {
|
||||
|
||||
const isUploading = uploadingCount > 0;
|
||||
|
||||
return {
|
||||
cancelUpload,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handlePaperclip,
|
||||
handlePaste,
|
||||
isDragOver,
|
||||
isUploading,
|
||||
pendingImeta,
|
||||
pendingImetaRef,
|
||||
removeAttachment,
|
||||
setPendingImeta,
|
||||
setUploadState,
|
||||
uploadFile,
|
||||
uploadingCount,
|
||||
uploadingPreviews,
|
||||
uploadState,
|
||||
};
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
cancelUpload,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handlePaperclip,
|
||||
handlePaste,
|
||||
isDragOver,
|
||||
isUploading,
|
||||
pendingImeta,
|
||||
pendingImetaRef,
|
||||
removeAttachment,
|
||||
setPendingImeta,
|
||||
setUploadState,
|
||||
uploadFile,
|
||||
uploadingCount,
|
||||
uploadingPreviews,
|
||||
uploadState,
|
||||
}),
|
||||
[
|
||||
cancelUpload,
|
||||
handleDragEnter,
|
||||
handleDragLeave,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
handlePaperclip,
|
||||
handlePaste,
|
||||
isDragOver,
|
||||
isUploading,
|
||||
pendingImeta,
|
||||
removeAttachment,
|
||||
setPendingImeta,
|
||||
uploadFile,
|
||||
uploadingCount,
|
||||
uploadingPreviews,
|
||||
uploadState,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
export type MediaUploadController = ReturnType<typeof useMediaUpload>;
|
||||
|
||||
@@ -53,7 +53,7 @@ export type AutocompleteEdit = {
|
||||
|
||||
export type RichTextEditorOptions = {
|
||||
placeholder?: string;
|
||||
onUpdate?: (info: { markdown: string; text: string }) => void;
|
||||
onUpdate?: (info: { text: string; cursor: number }) => void;
|
||||
editable?: boolean;
|
||||
mentionNames?: string[];
|
||||
agentMentionNames?: string[];
|
||||
@@ -459,13 +459,15 @@ export function useRichTextEditor({
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
const markdown = getMarkdownFromEditor(ed);
|
||||
// Use the same plain-text projection that `getPlainTextAndCursor`
|
||||
// uses, so autocomplete detection sees the *same* string the
|
||||
// cursor offset is mapped against. `state.doc.textContent` would
|
||||
// diverge by 1 per hard-break / block boundary.
|
||||
const text = buildPlainTextProjection(ed.state.doc).text;
|
||||
onUpdateRef.current?.({ markdown, text });
|
||||
// Keep the hot typing path lightweight. Markdown serialization is
|
||||
// still available through `getMarkdown()` for send/draft boundaries;
|
||||
// per-keystroke consumers only need textarea-shaped plain text for
|
||||
// autocomplete and empty/non-empty state.
|
||||
const projection = buildPlainTextProjection(ed.state.doc);
|
||||
onUpdateRef.current?.({
|
||||
cursor: projection.mapPMToTextOffset(ed.state.selection.anchor),
|
||||
text: projection.text,
|
||||
});
|
||||
},
|
||||
},
|
||||
[],
|
||||
|
||||
88
desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx
Normal file
88
desktop/src/features/messages/ui/ComposerReplyEditBanner.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { CornerUpLeft, Pencil, X } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
const BANNER_CLASS =
|
||||
"relative z-0 -mb-4 flex transform-gpu gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors";
|
||||
|
||||
/**
|
||||
* The "Editing message" / "Replying to …" banner that sits above the composer
|
||||
* input. Edit takes precedence over reply (matching the composer's own
|
||||
* `editTarget ? … : replyTarget ? …` ordering). Rendered as a sibling so
|
||||
* MessageComposer stays under the file-size guard; purely presentational.
|
||||
*/
|
||||
export function ComposerReplyEditBanner({
|
||||
isEditing,
|
||||
replyTarget,
|
||||
onCancelEdit,
|
||||
onCancelReply,
|
||||
}: {
|
||||
isEditing: boolean;
|
||||
replyTarget?: { author: string; body: string; id: string } | null;
|
||||
onCancelEdit?: () => void;
|
||||
onCancelReply?: () => void;
|
||||
}) {
|
||||
if (isEditing) {
|
||||
return (
|
||||
<div
|
||||
className={cn(BANNER_CLASS, "items-center")}
|
||||
data-testid="edit-target"
|
||||
>
|
||||
<Pencil aria-hidden className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
Editing message
|
||||
</p>
|
||||
</div>
|
||||
{onCancelEdit ? (
|
||||
<Button
|
||||
aria-label="Cancel edit"
|
||||
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancelEdit}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (replyTarget) {
|
||||
return (
|
||||
<div
|
||||
className={cn(BANNER_CLASS, "items-start")}
|
||||
data-testid="reply-target"
|
||||
>
|
||||
<CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
Replying to {replyTarget.author}
|
||||
</p>
|
||||
{replyTarget.body ? (
|
||||
<p className="truncate text-muted-foreground/80">
|
||||
{replyTarget.body}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{onCancelReply ? (
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { EditorContent } from "@tiptap/react";
|
||||
import { CornerUpLeft, Pencil, X } from "lucide-react";
|
||||
import { useChannelLinks } from "@/features/messages/lib/useChannelLinks";
|
||||
import { useComposerAutofocus } from "@/features/messages/lib/useComposerAutofocus";
|
||||
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
|
||||
@@ -40,8 +39,8 @@ import { useTypingBroadcast } from "@/features/messages/useTypingBroadcast";
|
||||
import { getBuzzCodeBlockClipboardText } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import type { ChannelType } from "@/shared/api/types";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ChannelAutocomplete } from "./ChannelAutocomplete";
|
||||
import { ComposerReplyEditBanner } from "./ComposerReplyEditBanner";
|
||||
import { ComposerAttachments, DropZoneOverlay } from "./ComposerAttachments";
|
||||
import { EmojiAutocomplete } from "./EmojiAutocomplete";
|
||||
import {
|
||||
@@ -51,6 +50,7 @@ import {
|
||||
import { MessageComposerToolbar } from "./MessageComposerToolbar";
|
||||
import { NonMemberMentionDialog } from "./NonMemberMentionDialog";
|
||||
import { useMentionSendFlow } from "./useMentionSendFlow";
|
||||
import { useComposerContentState } from "./useComposerContentState";
|
||||
|
||||
type MessageComposerProps = {
|
||||
channelId?: string | null;
|
||||
@@ -104,7 +104,7 @@ type MessageComposerProps = {
|
||||
typingRootEventId?: string | null;
|
||||
};
|
||||
|
||||
export function MessageComposer({
|
||||
function MessageComposerImpl({
|
||||
channelId = null,
|
||||
channelName,
|
||||
channelType = null,
|
||||
@@ -127,10 +127,14 @@ export function MessageComposer({
|
||||
typingParentEventId = null,
|
||||
typingRootEventId = null,
|
||||
}: MessageComposerProps) {
|
||||
const [content, setContent] = React.useState("");
|
||||
const contentRef = React.useRef(content);
|
||||
contentRef.current = content;
|
||||
|
||||
const {
|
||||
contentRef,
|
||||
isContentEmpty,
|
||||
setComposerContent,
|
||||
setComposerContentFromText,
|
||||
syncComposerContentFromEditor,
|
||||
syncContentRefFromEditorRef,
|
||||
} = useComposerContentState();
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
||||
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
|
||||
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
|
||||
@@ -238,14 +242,9 @@ export function MessageComposer({
|
||||
isAutocompleteOpen: isAutocompleteOpenRef,
|
||||
onEditLink: (info) => onEditLinkRef.current?.(info),
|
||||
onLinkSelectionChange: (info) => onLinkSelectionChangeRef.current?.(info),
|
||||
onUpdate: ({ markdown, text }) => {
|
||||
setContent(markdown);
|
||||
contentRef.current = markdown;
|
||||
onUpdate: ({ cursor, text }) => {
|
||||
setComposerContentFromText(text);
|
||||
|
||||
// Bridge to mention/channel/emoji detection hooks. `text` and the
|
||||
// cursor are both in plain-text-offset space (treating hardBreak
|
||||
// and inter-block boundaries as `\n`).
|
||||
const { cursor } = richText.getPlainTextAndCursor();
|
||||
mentions.updateMentionQuery(text, cursor);
|
||||
channelLinks.updateChannelQuery(text, cursor);
|
||||
emojiAutocomplete.updateEmojiQuery(text, cursor);
|
||||
@@ -257,6 +256,11 @@ export function MessageComposer({
|
||||
});
|
||||
|
||||
const linkEditor = useLinkEditor(richText);
|
||||
syncContentRefFromEditorRef.current = () => {
|
||||
const markdown = richText.getMarkdown();
|
||||
contentRef.current = markdown;
|
||||
return markdown;
|
||||
};
|
||||
onEditLinkRef.current = linkEditor.openFromClick;
|
||||
onLinkSelectionChangeRef.current = linkEditor.showFromCursor;
|
||||
useComposerSpoilerParticles(richText.editor, composerScrollRef);
|
||||
@@ -272,7 +276,7 @@ export function MessageComposer({
|
||||
mentions,
|
||||
onSendRef,
|
||||
richText,
|
||||
setContent,
|
||||
setContent: setComposerContent,
|
||||
setIsEmojiPickerOpen,
|
||||
setPendingImeta: media.setPendingImeta,
|
||||
setSpoileredAttachmentUrls,
|
||||
@@ -282,7 +286,7 @@ export function MessageComposer({
|
||||
React.useEffect(() => {
|
||||
const prevKey = previousDraftKeyRef.current;
|
||||
if (prevKey) {
|
||||
drafts.persistDraft(prevKey, contentRef.current);
|
||||
drafts.persistDraft(prevKey, syncComposerContentFromEditor());
|
||||
}
|
||||
previousDraftKeyRef.current = effectiveDraftKey;
|
||||
|
||||
@@ -290,12 +294,10 @@ export function MessageComposer({
|
||||
? drafts.loadDraft(effectiveDraftKey)
|
||||
: undefined;
|
||||
if (saved) {
|
||||
setContent(saved.content);
|
||||
contentRef.current = saved.content;
|
||||
setComposerContent(saved.content);
|
||||
richText.setContent(saved.content);
|
||||
} else {
|
||||
setContent("");
|
||||
contentRef.current = "";
|
||||
setComposerContent("");
|
||||
richText.clearContent();
|
||||
}
|
||||
|
||||
@@ -309,7 +311,7 @@ export function MessageComposer({
|
||||
|
||||
return () => {
|
||||
if (effectiveDraftKey) {
|
||||
drafts.persistDraft(effectiveDraftKey, contentRef.current);
|
||||
drafts.persistDraft(effectiveDraftKey, syncComposerContentFromEditor());
|
||||
}
|
||||
};
|
||||
}, [effectiveDraftKey]);
|
||||
@@ -321,7 +323,7 @@ export function MessageComposer({
|
||||
// in-flight work survives the edit-mode hijack and is restored on
|
||||
// edit-cancel/exit.
|
||||
preEditSnapshotRef.current = {
|
||||
content: contentRef.current,
|
||||
content: syncComposerContentFromEditor(),
|
||||
pendingImeta: [...media.pendingImetaRef.current],
|
||||
spoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
|
||||
};
|
||||
@@ -332,8 +334,7 @@ export function MessageComposer({
|
||||
editTarget.body,
|
||||
editTarget.imetaMedia ?? [],
|
||||
);
|
||||
setContent(editableBody);
|
||||
contentRef.current = editableBody;
|
||||
setComposerContent(editableBody);
|
||||
richText.setContent(editableBody);
|
||||
// Seed the composer's pending-imeta state with the original event's
|
||||
// attachments so they show up in `ComposerAttachments` and the user
|
||||
@@ -360,8 +361,7 @@ export function MessageComposer({
|
||||
spoileredAttachmentUrls: restoredSpoileredAttachmentUrls,
|
||||
} = preEditSnapshotRef.current;
|
||||
preEditSnapshotRef.current = null;
|
||||
setContent(restoredContent);
|
||||
contentRef.current = restoredContent;
|
||||
setComposerContent(restoredContent);
|
||||
restoredContent
|
||||
? richText.setContent(restoredContent)
|
||||
: richText.clearContent();
|
||||
@@ -502,7 +502,7 @@ export function MessageComposer({
|
||||
|
||||
// ── Submit message ──────────────────────────────────────────────────
|
||||
const submitMessage = React.useCallback(async () => {
|
||||
const trimmed = contentRef.current.trim();
|
||||
const trimmed = syncComposerContentFromEditor().trim();
|
||||
|
||||
// Edit mode
|
||||
if (editTargetRef.current && onEditSaveRef.current) {
|
||||
@@ -537,8 +537,7 @@ export function MessageComposer({
|
||||
const savedContent = trimmed;
|
||||
const savedImeta = [...currentPendingImeta];
|
||||
const savedSpoileredAttachmentUrls = new Set(spoileredAttachmentUrls);
|
||||
setContent("");
|
||||
contentRef.current = "";
|
||||
setComposerContent("");
|
||||
richText.clearContent();
|
||||
media.setPendingImeta([]);
|
||||
setSpoileredAttachmentUrls(new Set());
|
||||
@@ -550,8 +549,7 @@ export function MessageComposer({
|
||||
try {
|
||||
await onEditSaveRef.current(finalContent, outgoingTags);
|
||||
} catch {
|
||||
setContent(savedContent);
|
||||
contentRef.current = savedContent;
|
||||
setComposerContent(savedContent);
|
||||
richText.setContent(savedContent);
|
||||
media.setPendingImeta(savedImeta);
|
||||
setSpoileredAttachmentUrls(savedSpoileredAttachmentUrls);
|
||||
@@ -589,7 +587,9 @@ export function MessageComposer({
|
||||
mentions.clearMentions,
|
||||
richText.clearContent,
|
||||
richText.setContent,
|
||||
setComposerContent,
|
||||
spoileredAttachmentUrls,
|
||||
syncComposerContentFromEditor,
|
||||
]);
|
||||
submitMessageRef.current = submitMessage;
|
||||
|
||||
@@ -744,12 +744,12 @@ export function MessageComposer({
|
||||
disabled ||
|
||||
media.isUploading ||
|
||||
mentionSendFlow.isPreparingMentionSend ||
|
||||
(content.trim().length === 0 && media.pendingImeta.length === 0),
|
||||
(isContentEmpty && media.pendingImeta.length === 0),
|
||||
[
|
||||
disabled,
|
||||
media.isUploading,
|
||||
mentionSendFlow.isPreparingMentionSend,
|
||||
content,
|
||||
isContentEmpty,
|
||||
media.pendingImeta.length,
|
||||
],
|
||||
);
|
||||
@@ -825,60 +825,12 @@ export function MessageComposer({
|
||||
className="absolute inset-x-0 bottom-0 h-5 bg-background"
|
||||
/>
|
||||
<div className="relative flex w-full flex-col gap-0">
|
||||
{editTarget ? (
|
||||
<div
|
||||
className="relative z-0 -mb-4 flex transform-gpu items-center gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
|
||||
data-testid="edit-target"
|
||||
>
|
||||
<Pencil aria-hidden className="h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
Editing message
|
||||
</p>
|
||||
</div>
|
||||
{onCancelEdit ? (
|
||||
<Button
|
||||
aria-label="Cancel edit"
|
||||
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancelEdit}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : replyTarget ? (
|
||||
<div
|
||||
className="relative z-0 -mb-4 flex transform-gpu items-start gap-2 rounded-t-2xl border border-b-0 border-border/60 bg-muted/55 px-4 pb-6 pt-2.5 text-sm leading-5 text-muted-foreground backdrop-blur-sm transition-colors"
|
||||
data-testid="reply-target"
|
||||
>
|
||||
<CornerUpLeft aria-hidden className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-foreground">
|
||||
Replying to {replyTarget.author}
|
||||
</p>
|
||||
{replyTarget.body ? (
|
||||
<p className="truncate text-muted-foreground/80">
|
||||
{replyTarget.body}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{onCancelReply ? (
|
||||
<Button
|
||||
aria-label="Cancel reply"
|
||||
className="-mr-1 h-7 w-7 shrink-0 px-0 text-muted-foreground hover:text-foreground"
|
||||
onClick={onCancelReply}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<ComposerReplyEditBanner
|
||||
isEditing={editTarget != null}
|
||||
replyTarget={replyTarget}
|
||||
onCancelEdit={onCancelEdit}
|
||||
onCancelReply={onCancelReply}
|
||||
/>
|
||||
<form
|
||||
className="relative z-10 isolate rounded-2xl border border-border/50 bg-background/80 px-3 pb-2 pt-3 shadow-none backdrop-blur-md supports-[backdrop-filter]:bg-background/70 dark:bg-background/70 dark:backdrop-blur-xl dark:supports-[backdrop-filter]:bg-background/55 sm:px-4"
|
||||
data-testid="message-composer"
|
||||
@@ -996,3 +948,5 @@ export function MessageComposer({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const MessageComposer = React.memo(MessageComposerImpl);
|
||||
|
||||
39
desktop/src/features/messages/ui/useComposerContentState.ts
Normal file
39
desktop/src/features/messages/ui/useComposerContentState.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
|
||||
export function useComposerContentState() {
|
||||
const contentRef = React.useRef("");
|
||||
const [isContentEmpty, setIsContentEmpty] = React.useState(true);
|
||||
|
||||
const setComposerContentFromText = React.useCallback((nextText: string) => {
|
||||
setIsContentEmpty((wasEmpty) => {
|
||||
const isEmpty = nextText.trim().length === 0;
|
||||
return wasEmpty === isEmpty ? wasEmpty : isEmpty;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setComposerContent = React.useCallback(
|
||||
(nextContent: string) => {
|
||||
contentRef.current = nextContent;
|
||||
setComposerContentFromText(nextContent);
|
||||
},
|
||||
[setComposerContentFromText],
|
||||
);
|
||||
|
||||
const syncContentRefFromEditorRef = React.useRef<() => string>(
|
||||
() => contentRef.current,
|
||||
);
|
||||
|
||||
const syncComposerContentFromEditor = React.useCallback(
|
||||
() => syncContentRefFromEditorRef.current(),
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
contentRef,
|
||||
isContentEmpty,
|
||||
setComposerContent,
|
||||
setComposerContentFromText,
|
||||
syncComposerContentFromEditor,
|
||||
syncContentRefFromEditorRef,
|
||||
};
|
||||
}
|
||||
@@ -62,7 +62,7 @@ type UseMentionSendFlowOptions = {
|
||||
) => Promise<void>
|
||||
>;
|
||||
richText: Pick<UseRichTextEditorResult, "clearContent" | "setContent">;
|
||||
setContent: React.Dispatch<React.SetStateAction<string>>;
|
||||
setContent: (content: string) => void;
|
||||
setIsEmojiPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setPendingImeta: (pendingImeta: ImetaMedia[]) => void;
|
||||
setSpoileredAttachmentUrls?: React.Dispatch<
|
||||
|
||||
@@ -162,16 +162,29 @@ export function useChannelFind({
|
||||
}
|
||||
}, [channelId, reset]);
|
||||
|
||||
return {
|
||||
activeIndex,
|
||||
activeMatch,
|
||||
close,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
isOpen,
|
||||
matchCount: matchedIds.length,
|
||||
matchingMessageIds,
|
||||
query,
|
||||
setQuery,
|
||||
};
|
||||
return React.useMemo(
|
||||
() => ({
|
||||
activeIndex,
|
||||
activeMatch,
|
||||
close,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
isOpen,
|
||||
matchCount: matchedIds.length,
|
||||
matchingMessageIds,
|
||||
query,
|
||||
setQuery,
|
||||
}),
|
||||
[
|
||||
activeIndex,
|
||||
activeMatch,
|
||||
close,
|
||||
goToNext,
|
||||
goToPrevious,
|
||||
isOpen,
|
||||
matchedIds.length,
|
||||
matchingMessageIds,
|
||||
query,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
54
desktop/src/shared/hooks/useStableReference.ts
Normal file
54
desktop/src/shared/hooks/useStableReference.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Returns `next` but preserves the previous reference whenever the two Maps
|
||||
* have identical entries (same size, same key→value pairs). Lets a value that
|
||||
* is recomputed into a fresh Map on an unrelated invalidation (e.g. a version
|
||||
* bump) keep a stable identity so downstream `React.memo` boundaries can bail.
|
||||
*/
|
||||
export function useStableMap<K, V>(next: Map<K, V>): Map<K, V> {
|
||||
const ref = React.useRef(next);
|
||||
const prev = ref.current;
|
||||
if (prev !== next && mapsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
ref.current = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function mapsEqual<K, V>(a: Map<K, V>, b: Map<K, V>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [key, value] of a) {
|
||||
if (!b.has(key) || !Object.is(b.get(key), value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `next` but preserves the previous reference when the two arrays are
|
||||
* shallow-equal (same length, `Object.is` on each element). Same purpose as
|
||||
* `useStableMap` for array-valued derived state. Preserves the caller's exact
|
||||
* array type (mutable or readonly).
|
||||
*/
|
||||
export function useStableArrayShallow<T extends readonly unknown[]>(
|
||||
next: T,
|
||||
): T {
|
||||
const ref = React.useRef(next);
|
||||
const prev = ref.current;
|
||||
if (prev !== next && arraysShallowEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
ref.current = next;
|
||||
return next;
|
||||
}
|
||||
|
||||
function arraysShallowEqual(
|
||||
a: readonly unknown[],
|
||||
b: readonly unknown[],
|
||||
): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (!Object.is(a[i], b[i])) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -199,6 +199,11 @@ test("preserves user scroll while older channel history loads", async ({
|
||||
|
||||
// Snapshot the oldest rendered index BEFORE firing the delayed page, so the
|
||||
// poll's growth gate can require a genuinely NEW older index after it lands.
|
||||
// Wait for a concrete rendered row first: under CI load the initial rows can
|
||||
// lag, leaving the scan with nothing to match (null) and a spurious failure.
|
||||
await expect
|
||||
.poll(async () => await oldestRenderedIndex(), { timeout: 8_000 })
|
||||
.not.toBeNull();
|
||||
const oldestBeforeLanding = await oldestRenderedIndex();
|
||||
expect(oldestBeforeLanding).not.toBeNull();
|
||||
|
||||
@@ -243,7 +248,7 @@ test("preserves user scroll while older channel history loads", async ({
|
||||
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
|
||||
},
|
||||
{
|
||||
timeout: 3_000,
|
||||
timeout: 8_000,
|
||||
},
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
@@ -413,9 +418,12 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
|
||||
// messages land; we poll for that growth and then assert we are STILL
|
||||
// at the bottom (no upward teleport to the abandoned anchor).
|
||||
//
|
||||
// Timeout: 6s. The wheel-up + smooth-abandon path can burn 2-3s of
|
||||
// the 5s historyDelayMs window before this poll begins, so the
|
||||
// prepend may not land for another 2-3s. 6s leaves margin.
|
||||
// Timeout: 12s. The wheel-up + smooth-abandon path can burn 2-3s of the
|
||||
// 5s historyDelayMs window before this poll begins, so the prepend may not
|
||||
// land for another 2-3s. On a loaded CI runner that squeezes a tight 6s
|
||||
// budget into a timeout (the prepend is a deterministic 5s mock timer, so
|
||||
// the only failure mode is waiting too little); 12s leaves comfortable
|
||||
// margin without weakening the assertion below.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
@@ -424,7 +432,7 @@ test("does not teleport upward when user abandons fetch by jumping to bottom", a
|
||||
? "resolved"
|
||||
: "pending";
|
||||
},
|
||||
{ timeout: 6_000 },
|
||||
{ timeout: 12_000 },
|
||||
)
|
||||
.toBe("resolved");
|
||||
|
||||
@@ -1756,6 +1764,15 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
|
||||
return Number.isFinite(min) ? min : null;
|
||||
});
|
||||
|
||||
// Wait until at least one prepended "mock older" row has actually rendered
|
||||
// before sampling the oldest index. On a loaded CI runner the prepended
|
||||
// history can lag the scrollable-height gate above, leaving the scan with no
|
||||
// "mock older N" rows yet (oldestBeforeLanding === null). Poll for a concrete
|
||||
// older row so the baseline is deterministic.
|
||||
await expect
|
||||
.poll(async () => await oldestRenderedIndex(), { timeout: 8_000 })
|
||||
.not.toBeNull();
|
||||
|
||||
const oldestBeforeLanding = await oldestRenderedIndex();
|
||||
expect(oldestBeforeLanding).not.toBeNull();
|
||||
|
||||
@@ -1794,7 +1811,7 @@ test("older-history prepend keeps the reading row fixed (no jump to oldest)", as
|
||||
}
|
||||
return Math.abs(anchor.top - (anchorBeforeLanding?.top ?? 0));
|
||||
},
|
||||
{ timeout: 4_000 },
|
||||
{ timeout: 8_000 },
|
||||
)
|
||||
.toBeLessThanOrEqual(2);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user