mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
perf(desktop): memoize composer and timeline to fix typing lag (#143)
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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({
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<typeof useSendMessageMutation>;
|
||||
setReplyTargetId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
toggleReactionMutation: ReturnType<typeof useToggleReactionMutation>;
|
||||
}) {
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,10 +64,15 @@ export function MessageComposer({
|
||||
replyTarget = null,
|
||||
}: MessageComposerProps) {
|
||||
const [content, setContent] = React.useState("");
|
||||
const contentRef = React.useRef(content);
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const pendingSelectionRef = React.useRef<number | null>(null);
|
||||
const draftSelectionRef = React.useRef({ end: 0, start: 0 });
|
||||
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
|
||||
const lineHeightRef = React.useRef<number | null>(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<BlobDescriptor[]>([]);
|
||||
|
||||
// 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\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 (
|
||||
<footer className="border-t border-border/80 bg-background p-4">
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-3">
|
||||
@@ -545,19 +585,12 @@ export function MessageComposer({
|
||||
isEmojiPickerOpen={isEmojiPickerOpen}
|
||||
isSending={isSending}
|
||||
isUploading={isUploading}
|
||||
onCaptureSelection={() => {
|
||||
updateDraftSelection(textareaRef.current);
|
||||
}}
|
||||
onCaptureSelection={handleCaptureSelection}
|
||||
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
|
||||
onEmojiSelect={insertEmoji}
|
||||
onOpenMentionPicker={openMentionPicker}
|
||||
onPaperclip={() => {
|
||||
void handlePaperclip();
|
||||
}}
|
||||
sendDisabled={
|
||||
disabled ||
|
||||
(content.trim().length === 0 && pendingImeta.length === 0)
|
||||
}
|
||||
onPaperclip={handlePaperclipClick}
|
||||
sendDisabled={sendDisabled}
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,79 +1,82 @@
|
||||
import * as React from "react";
|
||||
import { AtSign, Paperclip, SendHorizontal } from "lucide-react";
|
||||
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { ComposerEmojiPicker } from "./ComposerEmojiPicker";
|
||||
|
||||
export function MessageComposerToolbar({
|
||||
composerDisabled,
|
||||
isEmojiPickerOpen,
|
||||
isSending,
|
||||
isUploading,
|
||||
onCaptureSelection,
|
||||
onEmojiPickerOpenChange,
|
||||
onEmojiSelect,
|
||||
onOpenMentionPicker,
|
||||
onPaperclip,
|
||||
sendDisabled,
|
||||
}: {
|
||||
composerDisabled: boolean;
|
||||
isEmojiPickerOpen: boolean;
|
||||
isSending: boolean;
|
||||
isUploading: boolean;
|
||||
onCaptureSelection: () => void;
|
||||
onEmojiPickerOpenChange: (open: boolean) => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onOpenMentionPicker: () => void;
|
||||
onPaperclip: () => void;
|
||||
sendDisabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-testid="message-insert-mention"
|
||||
disabled={composerDisabled}
|
||||
onClick={onOpenMentionPicker}
|
||||
onMouseDown={onCaptureSelection}
|
||||
size="icon"
|
||||
title="Mention someone"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={composerDisabled || isUploading}
|
||||
onClick={onPaperclip}
|
||||
size="icon"
|
||||
title="Attach image"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<ComposerEmojiPicker
|
||||
disabled={composerDisabled}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
onOpenChange={onEmojiPickerOpenChange}
|
||||
onTriggerMouseDown={onCaptureSelection}
|
||||
open={isEmojiPickerOpen}
|
||||
/>
|
||||
</div>
|
||||
export const MessageComposerToolbar = React.memo(
|
||||
function MessageComposerToolbar({
|
||||
composerDisabled,
|
||||
isEmojiPickerOpen,
|
||||
isSending,
|
||||
isUploading,
|
||||
onCaptureSelection,
|
||||
onEmojiPickerOpenChange,
|
||||
onEmojiSelect,
|
||||
onOpenMentionPicker,
|
||||
onPaperclip,
|
||||
sendDisabled,
|
||||
}: {
|
||||
composerDisabled: boolean;
|
||||
isEmojiPickerOpen: boolean;
|
||||
isSending: boolean;
|
||||
isUploading: boolean;
|
||||
onCaptureSelection: () => void;
|
||||
onEmojiPickerOpenChange: (open: boolean) => void;
|
||||
onEmojiSelect: (emoji: string) => void;
|
||||
onOpenMentionPicker: () => void;
|
||||
onPaperclip: () => void;
|
||||
sendDisabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
data-testid="message-insert-mention"
|
||||
disabled={composerDisabled}
|
||||
onClick={onOpenMentionPicker}
|
||||
onMouseDown={onCaptureSelection}
|
||||
size="icon"
|
||||
title="Mention someone"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
disabled={composerDisabled || isUploading}
|
||||
onClick={onPaperclip}
|
||||
size="icon"
|
||||
title="Attach image"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{isUploading ? (
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
<Paperclip className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<ComposerEmojiPicker
|
||||
disabled={composerDisabled}
|
||||
onEmojiSelect={onEmojiSelect}
|
||||
onOpenChange={onEmojiPickerOpenChange}
|
||||
onTriggerMouseDown={onCaptureSelection}
|
||||
open={isEmojiPickerOpen}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="gap-2"
|
||||
data-testid="send-message"
|
||||
disabled={sendDisabled || isSending}
|
||||
title="Send (Enter)"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
{isSending ? "Sending" : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Button
|
||||
className="gap-2"
|
||||
data-testid="send-message"
|
||||
disabled={sendDisabled || isSending}
|
||||
title="Send (Enter)"
|
||||
type="submit"
|
||||
>
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
{isSending ? "Sending" : "Send"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as React from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
|
||||
import type { TimelineMessage } from "@/features/messages/types";
|
||||
@@ -29,7 +30,7 @@ type MessageTimelineProps = {
|
||||
onTargetReached?: (messageId: string) => void;
|
||||
};
|
||||
|
||||
export function MessageTimeline({
|
||||
export const MessageTimeline = React.memo(function MessageTimeline({
|
||||
channelId,
|
||||
messages,
|
||||
isLoading = false,
|
||||
@@ -145,4 +146,4 @@ export function MessageTimeline({
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user