From 0ab21b9fdafdc1d721022ea149011a4b6dbf89ae Mon Sep 17 00:00:00 2001 From: Thomas Petersen Date: Mon, 18 May 2026 16:44:57 -0400 Subject: [PATCH] fix(desktop): simplify pulse composer and search Co-authored-by: Cursor --- .../src/features/forum/ui/ForumComposer.tsx | 246 +++++++++++------- .../forum/ui/ForumComposerAutocompletes.tsx | 43 +++ .../forum/ui/ForumComposerCompactLayout.tsx | 55 ++++ .../forum/ui/ForumComposerMediaStatus.tsx | 48 ++++ desktop/src/features/pulse/ui/PulseView.tsx | 16 +- 5 files changed, 307 insertions(+), 101 deletions(-) create mode 100644 desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx create mode 100644 desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx create mode 100644 desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index 064d38e926..ff86b0207f 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -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; + /** 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) => { + 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 (
{ + expandCompactComposer(); + media.handleDragEnter(event); + }} onDragLeave={media.handleDragLeave} onDragOver={media.handleDragOver} onDrop={(e) => { void media.handleDrop(e); }} + onFocusCapture={expandCompactComposer} onSubmit={handleSubmit} > {media.isDragOver && } - {header ?
{header}
: null} - - - - {media.uploadState.status === "error" ? ( -
- Upload failed: {media.uploadState.message} - -
- ) : null} - - {(media.pendingImeta.length > 0 || media.isUploading) && ( -
- -
- )} - - {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */} -
- -
- - + ) : ( + <> + {header ? ( +
- Cancel - - ) : 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} +
+ ) : null} + + + + + {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */} +
+ +
+ + + Cancel + + ) : 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} + /> + + )} ); } diff --git a/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx new file mode 100644 index 0000000000..9b271eaed6 --- /dev/null +++ b/desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx @@ -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 ( + <> + + + + ); +} diff --git a/desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx b/desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx new file mode 100644 index 0000000000..148d8bfd2e --- /dev/null +++ b/desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx @@ -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; + sendDisabled?: boolean; +}; + +export function ForumComposerCompactLayout({ + editor, + header, + isSending, + onEditorKeyDown, + sendDisabled, +}: ForumComposerCompactLayoutProps) { + return ( +
+ {header ? ( +
{header}
+ ) : null} + {/* biome-ignore lint/a11y/noStaticElementInteractions: keydown handler bridges Tiptap editor to autocomplete and submit */} +
+ +
+ +
+ ); +} diff --git a/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx b/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx new file mode 100644 index 0000000000..0ab10e7e6e --- /dev/null +++ b/desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx @@ -0,0 +1,48 @@ +import type { useMediaUpload } from "@/features/messages/lib/useMediaUpload"; +import { ComposerAttachments } from "@/features/messages/ui/ComposerAttachments"; + +type ComposerMedia = Pick< + ReturnType, + | "isUploading" + | "pendingImeta" + | "removeAttachment" + | "setUploadState" + | "uploadState" + | "uploadingCount" +>; + +type ForumComposerMediaStatusProps = { + media: ComposerMedia; +}; + +export function ForumComposerMediaStatus({ + media, +}: ForumComposerMediaStatusProps) { + return ( + <> + {media.uploadState.status === "error" ? ( +
+ Upload failed: {media.uploadState.message} + +
+ ) : null} + + {(media.pendingImeta.length > 0 || media.isUploading) && ( +
+ +
+ )} + + ); +} diff --git a/desktop/src/features/pulse/ui/PulseView.tsx b/desktop/src/features/pulse/ui/PulseView.tsx index c09a7b7919..38c1ade976 100644 --- a/desktop/src/features/pulse/ui/PulseView.tsx +++ b/desktop/src/features/pulse/ui/PulseView.tsx @@ -396,14 +396,10 @@ export function PulseView({ currentPubkey }: PulseViewProps) { {activeTab === "search" ? (
-
- Sprout +

+ Search Pulse +

-
+
+ + {currentDisplayName} +
} members={pulseMentionMembers}