mirror of
https://github.com/block/buzz.git
synced 2026-11-04 08:21:48 +00:00
fix(desktop): simplify pulse composer and search
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
43
desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx
Normal file
43
desktop/src/features/forum/ui/ForumComposerAutocompletes.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx
Normal file
55
desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx
Normal file
48
desktop/src/features/forum/ui/ForumComposerMediaStatus.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user