diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 30875b9e5..6345ef506 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { ChannelPane } from "@/app/ChannelPane"; import { useActiveChannelHeader } from "@/app/useActiveChannelHeader"; +import { useChannelPaneHandlers } from "@/app/useChannelPaneHandlers"; import { AgentsView } from "@/features/agents/ui/AgentsView"; import { ForumView } from "@/features/forum/ui/ForumView"; import { ChatHeader } from "@/features/chat/ui/ChatHeader"; @@ -203,6 +204,26 @@ export function AppShell() { [replyTargetId, timelineMessages], ); + const { handleCancelReply, handleReply, handleSend, handleToggleReaction } = + useChannelPaneHandlers({ + replyTargetId, + sendMessageMutation, + setReplyTargetId, + toggleReactionMutation, + }); + + const handleTargetReached = React.useCallback((messageId: string) => { + setSearchAnchor((current) => + current?.eventId === messageId ? null : current, + ); + }, []); + + const canReact = activeChannel !== null && activeChannel.archivedAt === null; + const effectiveToggleReaction = React.useMemo( + () => (canReact ? handleToggleReaction : undefined), + [canReact, handleToggleReaction], + ); + const channelDescription = activeChannel ? [ activeChannel.archivedAt ? "Archived." : null, @@ -664,39 +685,11 @@ export function AppShell() { isSending={sendMessageMutation.isPending} isTimelineLoading={isTimelineLoading} messages={timelineMessages} - onCancelReply={() => { - setReplyTargetId(null); - }} - onReply={(message) => { - setReplyTargetId((current) => - current === message.id ? null : message.id, - ); - }} - onSend={async (content, mentionPubkeys, mediaTags) => { - await sendMessageMutation.mutateAsync({ - content, - mentionPubkeys, - parentEventId: replyTargetId, - mediaTags, - }); - setReplyTargetId(null); - }} - onTargetReached={(messageId) => { - setSearchAnchor((current) => - current?.eventId === messageId ? null : current, - ); - }} - onToggleReaction={ - activeChannel && activeChannel.archivedAt === null - ? async (message, emoji, remove) => { - await toggleReactionMutation.mutateAsync({ - emoji, - eventId: message.id, - remove, - }); - } - : undefined - } + onCancelReply={handleCancelReply} + onReply={handleReply} + onSend={handleSend} + onTargetReached={handleTargetReached} + onToggleReaction={effectiveToggleReaction} profiles={messageProfiles} replyTargetId={replyTargetId} replyTargetMessage={replyTargetMessage} diff --git a/desktop/src/app/ChannelPane.tsx b/desktop/src/app/ChannelPane.tsx index 7178fbe00..52bf33ab3 100644 --- a/desktop/src/app/ChannelPane.tsx +++ b/desktop/src/app/ChannelPane.tsx @@ -33,7 +33,7 @@ type ChannelPaneProps = { typingPubkeys: string[]; }; -export function ChannelPane({ +export const ChannelPane = React.memo(function ChannelPane({ activeChannel, currentPubkey, isSending, @@ -118,4 +118,4 @@ export function ChannelPane({ /> ); -} +}); diff --git a/desktop/src/app/useChannelPaneHandlers.ts b/desktop/src/app/useChannelPaneHandlers.ts new file mode 100644 index 000000000..72a54737c --- /dev/null +++ b/desktop/src/app/useChannelPaneHandlers.ts @@ -0,0 +1,82 @@ +import * as React from "react"; + +import type { useSendMessageMutation } from "@/features/messages/hooks"; +import type { useToggleReactionMutation } from "@/features/messages/hooks"; + +/** + * Stable callback references for ChannelPane so that keystroke-driven + * re-renders of AppShell don't cascade into the timeline and composer. + * + * Mutation objects from TanStack Query v5 are new references on every render + * (especially when `isPending` flips), so we stash `.mutateAsync` in a ref + * rather than listing the whole mutation as a dependency. + */ +export function useChannelPaneHandlers({ + replyTargetId, + sendMessageMutation, + setReplyTargetId, + toggleReactionMutation, +}: { + replyTargetId: string | null; + sendMessageMutation: ReturnType; + setReplyTargetId: React.Dispatch>; + toggleReactionMutation: ReturnType; +}) { + // Keep mutable values in refs so callbacks never need to list them as deps. + const replyTargetIdRef = React.useRef(replyTargetId); + replyTargetIdRef.current = replyTargetId; + + const sendMutateRef = React.useRef(sendMessageMutation.mutateAsync); + sendMutateRef.current = sendMessageMutation.mutateAsync; + + const toggleMutateRef = React.useRef(toggleReactionMutation.mutateAsync); + toggleMutateRef.current = toggleReactionMutation.mutateAsync; + + const handleCancelReply = React.useCallback(() => { + setReplyTargetId(null); + }, [setReplyTargetId]); + + const handleReply = React.useCallback( + (message: { id: string }) => { + setReplyTargetId((current) => + current === message.id ? null : message.id, + ); + }, + [setReplyTargetId], + ); + + const handleSend = React.useCallback( + async ( + content: string, + mentionPubkeys: string[], + mediaTags?: string[][], + ) => { + await sendMutateRef.current({ + content, + mentionPubkeys, + parentEventId: replyTargetIdRef.current, + mediaTags, + }); + setReplyTargetId(null); + }, + [setReplyTargetId], + ); + + const handleToggleReaction = React.useCallback( + async (message: { id: string }, emoji: string, remove: boolean) => { + await toggleMutateRef.current({ + emoji, + eventId: message.id, + remove, + }); + }, + [], + ); + + return { + handleCancelReply, + handleReply, + handleSend, + handleToggleReaction, + }; +} diff --git a/desktop/src/features/messages/ui/ChannelAutocomplete.tsx b/desktop/src/features/messages/ui/ChannelAutocomplete.tsx index 12654626b..cbf318d15 100644 --- a/desktop/src/features/messages/ui/ChannelAutocomplete.tsx +++ b/desktop/src/features/messages/ui/ChannelAutocomplete.tsx @@ -9,7 +9,7 @@ type ChannelAutocompleteProps = { onSelect: (suggestion: ChannelSuggestion) => void; }; -export function ChannelAutocomplete({ +export const ChannelAutocomplete = React.memo(function ChannelAutocomplete({ suggestions, selectedIndex, onSelect, @@ -58,4 +58,4 @@ export function ChannelAutocomplete({ ); -} +}); diff --git a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx index 0f81fe64d..78e5e98cf 100644 --- a/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx +++ b/desktop/src/features/messages/ui/ComposerEmojiPicker.tsx @@ -1,3 +1,4 @@ +import * as React from "react"; import Picker from "@emoji-mart/react"; import data from "@emoji-mart/data"; import { SmilePlus } from "lucide-react"; @@ -13,7 +14,7 @@ type ComposerEmojiPickerProps = { open: boolean; }; -export function ComposerEmojiPicker({ +export const ComposerEmojiPicker = React.memo(function ComposerEmojiPicker({ disabled = false, onEmojiSelect, onOpenChange, @@ -55,4 +56,4 @@ export function ComposerEmojiPicker({ ); -} +}); diff --git a/desktop/src/features/messages/ui/MentionAutocomplete.tsx b/desktop/src/features/messages/ui/MentionAutocomplete.tsx index 9112e392a..d44e3c858 100644 --- a/desktop/src/features/messages/ui/MentionAutocomplete.tsx +++ b/desktop/src/features/messages/ui/MentionAutocomplete.tsx @@ -14,7 +14,7 @@ type MentionAutocompleteProps = { onSelect: (suggestion: MentionSuggestion) => void; }; -export function MentionAutocomplete({ +export const MentionAutocomplete = React.memo(function MentionAutocomplete({ suggestions, selectedIndex, onSelect, @@ -67,4 +67,4 @@ export function MentionAutocomplete({ ); -} +}); diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx index 350885ddd..3aea663f0 100644 --- a/desktop/src/features/messages/ui/MessageComposer.tsx +++ b/desktop/src/features/messages/ui/MessageComposer.tsx @@ -64,10 +64,15 @@ export function MessageComposer({ replyTarget = null, }: MessageComposerProps) { const [content, setContent] = React.useState(""); + const contentRef = React.useRef(content); const textareaRef = React.useRef(null); const pendingSelectionRef = React.useRef(null); const draftSelectionRef = React.useRef({ end: 0, start: 0 }); const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false); + const lineHeightRef = React.useRef(null); + + // Keep contentRef in sync — no extra re-render, just a ref assignment. + contentRef.current = content; const mentions = useMentions(channelId); const channelLinks = useChannelLinks(); @@ -78,9 +83,21 @@ export function MessageComposer({ }>({ status: "idle" }); const [pendingImeta, setPendingImeta] = React.useState([]); + // Stable refs for values read inside callbacks that should not cause + // callback identity changes when they update. + const pendingImetaRef = React.useRef(pendingImeta); + const disabledRef = React.useRef(disabled); + const isSendingRef = React.useRef(isSending); + const onSendRef = React.useRef(onSend); + pendingImetaRef.current = pendingImeta; + disabledRef.current = disabled; + isSendingRef.current = isSending; + onSendRef.current = onSend; + // biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the sole trigger — reset all composer state on channel switch to prevent draft/upload/autocomplete leaks React.useEffect(() => { setContent(""); + contentRef.current = ""; setPendingImeta([]); setUploadState({ status: "idle" }); setIsEmojiPickerOpen(false); @@ -88,14 +105,17 @@ export function MessageComposer({ channelLinks.clearChannels(); draftSelectionRef.current = { end: 0, start: 0 }; pendingSelectionRef.current = null; + lineHeightRef.current = null; }, [channelId]); + const applyMentionInsert = React.useCallback( (suggestion: MentionSuggestion) => { const textarea = textareaRef.current; + const currentContent = contentRef.current; const result = mentions.insertMention( suggestion, - content, - textarea?.selectionEnd ?? content.length, + currentContent, + textarea?.selectionEnd ?? currentContent.length, ); draftSelectionRef.current = { end: result.nextCursor, @@ -104,16 +124,17 @@ export function MessageComposer({ pendingSelectionRef.current = result.nextCursor; setContent(result.nextContent); }, - [content, mentions.insertMention], + [mentions.insertMention], ); const applyChannelInsert = React.useCallback( (suggestion: ChannelSuggestion) => { const textarea = textareaRef.current; + const currentContent = contentRef.current; const result = channelLinks.insertChannel( suggestion, - content, - textarea?.selectionEnd ?? content.length, + currentContent, + textarea?.selectionEnd ?? currentContent.length, ); draftSelectionRef.current = { end: result.nextCursor, @@ -122,7 +143,7 @@ export function MessageComposer({ pendingSelectionRef.current = result.nextCursor; setContent(result.nextContent); }, - [content, channelLinks.insertChannel], + [channelLinks.insertChannel], ); const updateDraftSelection = React.useCallback( @@ -141,11 +162,12 @@ export function MessageComposer({ const insertEmoji = React.useCallback( (emoji: string) => { + const currentContent = contentRef.current; const { end, start } = draftSelectionRef.current; - const nextStart = Math.min(start, content.length); - const nextEnd = Math.min(end, content.length); + const nextStart = Math.min(start, currentContent.length); + const nextEnd = Math.min(end, currentContent.length); const nextCursor = nextStart + emoji.length; - const nextContent = `${content.slice(0, nextStart)}${emoji}${content.slice(nextEnd)}`; + const nextContent = `${currentContent.slice(0, nextStart)}${emoji}${currentContent.slice(nextEnd)}`; draftSelectionRef.current = { end: nextCursor, @@ -156,7 +178,7 @@ export function MessageComposer({ setIsEmojiPickerOpen(false); mentions.clearMentions(); }, - [content, mentions.clearMentions], + [mentions.clearMentions], ); const openMentionPicker = React.useCallback(() => { @@ -165,23 +187,24 @@ export function MessageComposer({ return; } - const cursorPosition = textarea.selectionStart ?? content.length; - const existingMention = detectMentionQuery(content, cursorPosition); + const currentContent = contentRef.current; + const cursorPosition = textarea.selectionStart ?? currentContent.length; + const existingMention = detectMentionQuery(currentContent, cursorPosition); if (existingMention) { - mentions.updateMentionQuery(content, cursorPosition); + mentions.updateMentionQuery(currentContent, cursorPosition); textarea.focus(); return; } const { end, start } = draftSelectionRef.current; - const nextStart = Math.min(start, content.length); - const nextEnd = Math.min(end, content.length); - const previousCharacter = content.slice(0, nextStart).slice(-1); + const nextStart = Math.min(start, currentContent.length); + const nextEnd = Math.min(end, currentContent.length); + const previousCharacter = currentContent.slice(0, nextStart).slice(-1); const prefix = nextStart > 0 && previousCharacter && !/\s/.test(previousCharacter) ? " @" : "@"; - const nextContent = `${content.slice(0, nextStart)}${prefix}${content.slice(nextEnd)}`; + const nextContent = `${currentContent.slice(0, nextStart)}${prefix}${currentContent.slice(nextEnd)}`; const mentionIndex = nextStart + (prefix.startsWith(" ") ? 1 : 0); const nextCursor = mentionIndex + 1; @@ -193,7 +216,7 @@ export function MessageComposer({ setContent(nextContent); setIsEmojiPickerOpen(false); mentions.updateMentionQuery(nextContent, nextCursor); - }, [content, mentions.updateMentionQuery]); + }, [mentions.updateMentionQuery]); const onUploaded = React.useCallback((descriptor: BlobDescriptor) => { const markdown = `\n![image](${descriptor.url})\n`; @@ -287,17 +310,22 @@ export function MessageComposer({ ); const submitMessage = React.useCallback(async () => { - const trimmed = content.trim(); - const hasMedia = pendingImeta.length > 0; - if ((!trimmed && !hasMedia) || disabled || isSending) { + const trimmed = contentRef.current.trim(); + const currentPendingImeta = pendingImetaRef.current; + const hasMedia = currentPendingImeta.length > 0; + if ( + (!trimmed && !hasMedia) || + disabledRef.current || + isSendingRef.current + ) { return; } const pubkeys = mentions.extractMentionPubkeys(trimmed); const mediaTags = - pendingImeta.length > 0 - ? pendingImeta.map((d) => [ + currentPendingImeta.length > 0 + ? currentPendingImeta.map((d) => [ "imeta", `url ${d.url}`, `m ${d.type}`, @@ -310,7 +338,7 @@ export function MessageComposer({ : undefined; const savedContent = trimmed; - const savedImeta = [...pendingImeta]; + const savedImeta = [...currentPendingImeta]; setContent(""); draftSelectionRef.current = { end: 0, start: 0 }; @@ -320,20 +348,15 @@ export function MessageComposer({ setIsEmojiPickerOpen(false); try { - await onSend(trimmed, pubkeys, mediaTags); + await onSendRef.current(trimmed, pubkeys, mediaTags); } catch { setContent(savedContent); setPendingImeta(savedImeta); } }, [ - content, - disabled, - isSending, - onSend, mentions.extractMentionPubkeys, mentions.clearMentions, channelLinks.clearChannels, - pendingImeta, ]); const handleSubmit = React.useCallback( @@ -420,8 +443,11 @@ export function MessageComposer({ return; } - const lineHeight = - Number.parseFloat(window.getComputedStyle(textarea).lineHeight) || 24; + if (lineHeightRef.current === null) { + lineHeightRef.current = + Number.parseFloat(window.getComputedStyle(textarea).lineHeight) || 24; + } + const lineHeight = lineHeightRef.current; const maxHeight = lineHeight * MAX_TEXTAREA_ROWS; textarea.style.height = "auto"; @@ -451,6 +477,20 @@ export function MessageComposer({ const isUploading = uploadState.status === "uploading"; + const sendDisabled = React.useMemo( + () => + disabled || (content.trim().length === 0 && pendingImeta.length === 0), + [disabled, content, pendingImeta.length], + ); + + const handleCaptureSelection = React.useCallback(() => { + updateDraftSelection(textareaRef.current); + }, [updateDraftSelection]); + + const handlePaperclipClick = React.useCallback(() => { + void handlePaperclip(); + }, [handlePaperclip]); + return (