fix(desktop): simplify pulse composer and search

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Thomas Petersen
2026-05-18 16:44:57 -04:00
parent dd31334a3b
commit 0ab21b9fda
5 changed files with 307 additions and 101 deletions

View File

@@ -13,20 +13,16 @@ import {
normalizeMentionClipboardHtml,
} from "@/features/messages/lib/normalizeMentionClipboard";
import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import {
ComposerAttachments,
DropZoneOverlay,
} from "@/features/messages/ui/ComposerAttachments";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "@/features/messages/ui/MentionAutocomplete";
import { DropZoneOverlay } from "@/features/messages/ui/ComposerAttachments";
import type { MentionSuggestion } from "@/features/messages/ui/MentionAutocomplete";
import { MessageComposerToolbar } from "@/features/messages/ui/MessageComposerToolbar";
import type { ChannelMember } from "@/shared/api/types";
import type { UserProfileLookup } from "@/features/profile/lib/identity";
import { Button } from "@/shared/ui/button";
import { cn } from "@/shared/lib/cn";
import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes";
import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout";
import { ForumComposerMediaStatus } from "./ForumComposerMediaStatus";
type ForumComposerProps = {
channelId?: string | null;
@@ -43,6 +39,8 @@ type ForumComposerProps = {
mentionPubkeys: string[],
mediaTags?: string[][],
) => undefined | Promise<unknown>;
/** Render as a single-line composer until the user focuses it. */
compact?: boolean;
/** When true, autocomplete renders below the input (for top-of-view composers). */
autocompleteBelow?: boolean;
profiles?: UserProfileLookup;
@@ -58,6 +56,7 @@ export function ForumComposer({
isSending,
onCancel,
onSubmit,
compact = false,
autocompleteBelow = false,
profiles,
}: ForumComposerProps) {
@@ -65,6 +64,7 @@ export function ForumComposer({
const contentRef = React.useRef(content);
contentRef.current = content;
const [isCompactExpanded, setIsCompactExpanded] = React.useState(!compact);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
@@ -107,7 +107,6 @@ export function ForumComposer({
},
});
// ── Mention / channel autocomplete insertion ────────────────────────
const applyMentionInsert = React.useCallback(
(suggestion: MentionSuggestion) => {
const { text, cursor } = richText.getTextAndCursor();
@@ -138,7 +137,6 @@ export function ForumComposer({
],
);
// ── Emoji insertion ─────────────────────────────────────────────────
const insertEmoji = React.useCallback(
(emoji: string) => {
if (!richText.editor) return;
@@ -228,17 +226,24 @@ export function ForumComposer({
setIsEmojiPickerOpen(false);
const result = onSubmitRef.current(finalContent, pubkeys, mediaTags);
const collapseCompactComposer = () => {
if (compact) setIsCompactExpanded(false);
};
// If onSubmit returns a promise, restore draft on failure.
if (result && typeof result.then === "function") {
result.catch(() => {
result.then(collapseCompactComposer).catch(() => {
setContent(savedContent);
contentRef.current = savedContent;
richText.setContent(savedContent);
media.setPendingImeta(savedImeta);
if (compact) setIsCompactExpanded(true);
});
} else {
collapseCompactComposer();
}
}, [
compact,
media.pendingImetaRef,
media.setPendingImeta,
mentions.extractMentionPubkeys,
@@ -327,113 +332,168 @@ export function ForumComposer({
});
}, [richText.editor]);
// ── Send button state ───────────────────────────────────────────────
const sendDisabled = React.useMemo(
() =>
disabled ||
(content.trim().length === 0 && media.pendingImeta.length === 0),
[disabled, content, media.pendingImeta.length],
);
const handlePaperclipClick = React.useCallback(() => {
void media.handlePaperclip();
}, [media.handlePaperclip]);
const expandCompactComposer = React.useCallback(() => {
if (compact) setIsCompactExpanded(true);
}, [compact]);
const hasComposerContent =
content.trim().length > 0 ||
media.pendingImeta.length > 0 ||
media.isUploading ||
media.uploadState.status === "error";
const isExpanded =
!compact ||
isCompactExpanded ||
hasComposerContent ||
isEmojiPickerOpen ||
isFormattingOpen ||
mentions.isMentionOpen ||
channelLinks.isChannelOpen;
const isCompactLayout = compact && !isExpanded;
const handleFormBlur = React.useCallback(
(event: React.FocusEvent<HTMLFormElement>) => {
if (!compact) return;
// ── Render ──────────────────────────────────────────────────────────
const nextTarget = event.relatedTarget;
if (
nextTarget instanceof Node &&
event.currentTarget.contains(nextTarget)
) {
return;
}
const hasDraft =
contentRef.current.trim().length > 0 ||
media.pendingImetaRef.current.length > 0 ||
media.isUploading ||
media.uploadState.status === "error" ||
isEmojiPickerOpen ||
isFormattingOpen;
if (!hasDraft) setIsCompactExpanded(false);
},
[
compact,
isEmojiPickerOpen,
isFormattingOpen,
media.isUploading,
media.pendingImetaRef,
media.uploadState.status,
],
);
const wasCompactExpandedRef = React.useRef(isCompactExpanded);
React.useEffect(() => {
const wasExpanded = wasCompactExpandedRef.current;
wasCompactExpandedRef.current = isCompactExpanded;
if (!compact || !isCompactExpanded || wasExpanded) return;
const frame = window.requestAnimationFrame(() => {
richText.focus();
});
return () => window.cancelAnimationFrame(frame);
}, [compact, isCompactExpanded, richText.focus]);
const autocompletePosition = autocompleteBelow ? "below" : "above";
return (
<form
className={cn(
"relative rounded-2xl border border-input bg-card px-3 py-2 sm:px-4",
className,
)}
onDragEnter={media.handleDragEnter}
onBlurCapture={handleFormBlur}
onDragEnter={(event) => {
expandCompactComposer();
media.handleDragEnter(event);
}}
onDragLeave={media.handleDragLeave}
onDragOver={media.handleDragOver}
onDrop={(e) => {
void media.handleDrop(e);
}}
onFocusCapture={expandCompactComposer}
onSubmit={handleSubmit}
>
{media.isDragOver && <DropZoneOverlay />}
{header ? <div className="mb-2">{header}</div> : null}
<ChannelAutocomplete
onSelect={applyChannelInsert}
position={autocompletePosition}
selectedIndex={channelLinks.channelSelectedIndex}
suggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
/>
<MentionAutocomplete
onSelect={applyMentionInsert}
position={autocompletePosition}
selectedIndex={mentions.mentionSelectedIndex}
suggestions={mentions.isMentionOpen ? mentions.suggestions : []}
/>
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
Dismiss
</button>
</div>
) : null}
{(media.pendingImeta.length > 0 || media.isUploading) && (
<div className="mb-2 flex items-center gap-2">
<ComposerAttachments
attachments={media.pendingImeta}
isUploading={media.isUploading}
uploadingCount={media.uploadingCount}
onRemove={media.removeAttachment}
/>
</div>
)}
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
</div>
<MessageComposerToolbar
composerDisabled={disabled ?? false}
editor={richText.editor}
extraActions={
onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
{isCompactLayout ? (
<ForumComposerCompactLayout
editor={richText.editor}
header={header}
isSending={isSending}
onEditorKeyDown={handleEditorKeyDown}
sendDisabled={sendDisabled}
/>
) : (
<>
{header ? (
<div
className={cn("mb-2", compact && "flex min-h-10 items-center")}
>
Cancel
</Button>
) : undefined
}
formattingDisabled={disabled ?? false}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending ?? false}
isUploading={media.isUploading}
onCaptureSelection={() => {}}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
{header}
</div>
) : null}
<ForumComposerAutocompletes
channelSelectedIndex={channelLinks.channelSelectedIndex}
channelSuggestions={
channelLinks.isChannelOpen ? channelLinks.channelSuggestions : []
}
mentionSelectedIndex={mentions.mentionSelectedIndex}
mentionSuggestions={
mentions.isMentionOpen ? mentions.suggestions : []
}
onChannelSelect={applyChannelInsert}
onMentionSelect={applyMentionInsert}
position={autocompletePosition}
/>
<ForumComposerMediaStatus media={media} />
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-32 overflow-y-auto"
onKeyDown={handleEditorKeyDown}
>
<EditorContent editor={richText.editor} />
</div>
<MessageComposerToolbar
composerDisabled={disabled ?? false}
editor={richText.editor}
extraActions={
onCancel ? (
<Button
disabled={isSending}
onClick={onCancel}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
) : undefined
}
formattingDisabled={disabled ?? false}
isEmojiPickerOpen={isEmojiPickerOpen}
isFormattingOpen={isFormattingOpen}
isSending={isSending ?? false}
isUploading={media.isUploading}
onCaptureSelection={() => {}}
onEmojiPickerOpenChange={setIsEmojiPickerOpen}
onEmojiSelect={insertEmoji}
onFormattingToggle={handleFormattingToggle}
onOpenMentionPicker={openMentionPicker}
onPaperclip={handlePaperclipClick}
sendDisabled={sendDisabled}
/>
</>
)}
</form>
);
}

View File

@@ -0,0 +1,43 @@
import type { ChannelSuggestion } from "@/features/messages/lib/useChannelLinks";
import { ChannelAutocomplete } from "@/features/messages/ui/ChannelAutocomplete";
import {
MentionAutocomplete,
type MentionSuggestion,
} from "@/features/messages/ui/MentionAutocomplete";
type ForumComposerAutocompletesProps = {
channelSelectedIndex: number;
channelSuggestions: ChannelSuggestion[];
mentionSelectedIndex: number;
mentionSuggestions: MentionSuggestion[];
onChannelSelect: (suggestion: ChannelSuggestion) => void;
onMentionSelect: (suggestion: MentionSuggestion) => void;
position: "above" | "below";
};
export function ForumComposerAutocompletes({
channelSelectedIndex,
channelSuggestions,
mentionSelectedIndex,
mentionSuggestions,
onChannelSelect,
onMentionSelect,
position,
}: ForumComposerAutocompletesProps) {
return (
<>
<ChannelAutocomplete
onSelect={onChannelSelect}
position={position}
selectedIndex={channelSelectedIndex}
suggestions={channelSuggestions}
/>
<MentionAutocomplete
onSelect={onMentionSelect}
position={position}
selectedIndex={mentionSelectedIndex}
suggestions={mentionSuggestions}
/>
</>
);
}

View File

@@ -0,0 +1,55 @@
import type * as React from "react";
import { EditorContent, type Editor } from "@tiptap/react";
import { ArrowUp } from "lucide-react";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
type ForumComposerCompactLayoutProps = {
editor: Editor | null;
header?: React.ReactNode;
isSending?: boolean;
onEditorKeyDown: React.KeyboardEventHandler<HTMLDivElement>;
sendDisabled?: boolean;
};
export function ForumComposerCompactLayout({
editor,
header,
isSending,
onEditorKeyDown,
sendDisabled,
}: ForumComposerCompactLayoutProps) {
return (
<div className="flex min-h-10 items-center gap-3">
{header ? (
<div className="flex min-w-0 shrink-0 items-center">{header}</div>
) : null}
{/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */}
<div
className="rich-text-composer max-h-10 min-w-0 flex-1 overflow-y-auto"
onKeyDown={onEditorKeyDown}
>
<EditorContent editor={editor} />
</div>
<Button
aria-label={isSending ? "Sending" : "Send message"}
className={cn("h-9 w-9 shrink-0 rounded-full")}
data-testid="send-message"
disabled={sendDisabled || isSending}
size="icon"
type="submit"
>
{isSending ? (
<span
aria-hidden
className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary-foreground border-t-transparent"
/>
) : (
<ArrowUp aria-hidden className="h-3.5 w-3.5" />
)}
</Button>
</div>
);
}

View File

@@ -0,0 +1,48 @@
import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload";
import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments";
type ComposerMedia = Pick<
ReturnType<typeof useMediaUpload>,
| "isUploading"
| "pendingImeta"
| "removeAttachment"
| "setUploadState"
| "uploadState"
| "uploadingCount"
>;
type ForumComposerMediaStatusProps = {
media: ComposerMedia;
};
export function ForumComposerMediaStatus({
media,
}: ForumComposerMediaStatusProps) {
return (
<>
{media.uploadState.status === "error" ? (
<div className="mb-2 rounded-lg bg-destructive/10 px-3 py-2 text-xs text-destructive">
Upload failed: {media.uploadState.message}
<button
className="ml-2 underline"
onClick={() => media.setUploadState({ status: "idle" })}
type="button"
>
Dismiss
</button>
</div>
) : null}
{(media.pendingImeta.length > 0 || media.isUploading) && (
<div className="mb-2 flex items-center gap-2">
<ComposerAttachments
attachments={media.pendingImeta}
isUploading={media.isUploading}
onRemove={media.removeAttachment}
uploadingCount={media.uploadingCount}
/>
</div>
)}
</>
);
}

View File

@@ -396,14 +396,10 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
{activeTab === "search" ? (
<div className="flex min-h-[56vh] items-center justify-center">
<div className="relative flex w-full max-w-xl flex-col items-center px-2">
<div className="pointer-events-none absolute -inset-x-24 -inset-y-20 rounded-full bg-[radial-gradient(circle_at_center,rgba(255,255,255,0.10),rgba(255,255,255,0)_56%)] opacity-80" />
<img
alt="Sprout"
className="mb-7 h-16 w-16 object-contain opacity-95 dark:invert"
src="/sprout.svg"
/>
<h2 className="mb-5 text-center text-2xl font-semibold tracking-tight text-foreground">
Search Pulse
</h2>
<div className="relative w-full max-w-lg">
<div className="pointer-events-none absolute -inset-2 rounded-full bg-foreground/[0.03] blur-md dark:bg-white/[0.08]" />
<div className="relative rounded-full border border-foreground/10 bg-background/80 p-1 shadow-[0_12px_48px_rgba(0,0,0,0.12)] backdrop-blur-xl dark:border-white/10 dark:bg-white/[0.04] dark:shadow-[0_16px_70px_rgba(0,0,0,0.55)]">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground dark:text-white/55" />
<Input
@@ -437,13 +433,17 @@ export function PulseView({ currentPubkey }: PulseViewProps) {
<ForumComposer
autocompleteBelow
className="pulse-composer rounded-xl border-border/60 bg-muted/30 p-2 shadow-none"
compact
header={
<div className="pt-1">
<div className="flex min-w-0 items-center gap-2">
<UserAvatar
avatarUrl={currentProfile?.avatarUrl ?? null}
className="!h-7 !w-7 shrink-0"
displayName={currentDisplayName}
/>
<span className="max-w-32 truncate text-sm font-medium text-foreground">
{currentDisplayName}
</span>
</div>
}
members={pulseMentionMembers}