diff --git a/desktop/src/features/forum/ui/ForumComposer.tsx b/desktop/src/features/forum/ui/ForumComposer.tsx index ff86b0207f..9ecf981fda 100644 --- a/desktop/src/features/forum/ui/ForumComposer.tsx +++ b/desktop/src/features/forum/ui/ForumComposer.tsx @@ -16,35 +16,13 @@ import { useRichTextEditor } from "@/features/messages/lib/useRichTextEditor"; 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 type { ForumComposerProps } from "./ForumComposer.types"; import { ForumComposerAutocompletes } from "./ForumComposerAutocompletes"; import { ForumComposerCompactLayout } from "./ForumComposerCompactLayout"; import { ForumComposerMediaStatus } from "./ForumComposerMediaStatus"; - -type ForumComposerProps = { - channelId?: string | null; - /** Override mention source when no channel is available (e.g. Pulse). */ - members?: ChannelMember[]; - className?: string; - placeholder: string; - disabled?: boolean; - header?: React.ReactNode; - isSending?: boolean; - onCancel?: () => void; - onSubmit: ( - content: string, - 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; -}; +import { useCompactComposerInteractions } from "./useCompactComposerInteractions"; export function ForumComposer({ channelId = null, @@ -72,10 +50,19 @@ export function ForumComposer({ if (pressed) setIsEmojiPickerOpen(false); setIsFormattingOpen(pressed); }, []); + const expandCompactComposer = React.useCallback(() => { + if (compact) setIsCompactExpanded(true); + }, [compact]); const mentions = useMentions(channelId, members, profiles); const channelLinks = useChannelLinks(); const media = useMediaUpload(); + const { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur } = + useCompactComposerInteractions({ + compact, + onExpand: expandCompactComposer, + onPaperclip: media.handlePaperclip, + }); const disabledRef = React.useRef(disabled); const isSendingRef = React.useRef(isSending); @@ -338,12 +325,6 @@ export function ForumComposer({ (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 || @@ -369,6 +350,9 @@ export function ForumComposer({ ) { return; } + if (shouldIgnoreBlur()) { + return; + } const hasDraft = contentRef.current.trim().length > 0 || @@ -387,6 +371,7 @@ export function ForumComposer({ media.isUploading, media.pendingImetaRef, media.uploadState.status, + shouldIgnoreBlur, ], ); const wasCompactExpandedRef = React.useRef(isCompactExpanded); @@ -484,7 +469,7 @@ export function ForumComposer({ isFormattingOpen={isFormattingOpen} isSending={isSending ?? false} isUploading={media.isUploading} - onCaptureSelection={() => {}} + onCaptureSelection={handleToolbarMouseDown} onEmojiPickerOpenChange={setIsEmojiPickerOpen} onEmojiSelect={insertEmoji} onFormattingToggle={handleFormattingToggle} diff --git a/desktop/src/features/forum/ui/ForumComposer.types.ts b/desktop/src/features/forum/ui/ForumComposer.types.ts new file mode 100644 index 0000000000..e72a4edcf4 --- /dev/null +++ b/desktop/src/features/forum/ui/ForumComposer.types.ts @@ -0,0 +1,26 @@ +import type * as React from "react"; + +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ChannelMember } from "@/shared/api/types"; + +export type ForumComposerProps = { + channelId?: string | null; + /** Override mention source when no channel is available (e.g. Pulse). */ + members?: ChannelMember[]; + className?: string; + placeholder: string; + disabled?: boolean; + header?: React.ReactNode; + isSending?: boolean; + onCancel?: () => void; + onSubmit: ( + content: string, + 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; +}; diff --git a/desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx b/desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx index 148d8bfd2e..e482e01421 100644 --- a/desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx +++ b/desktop/src/features/forum/ui/ForumComposerCompactLayout.tsx @@ -1,7 +1,7 @@ import type * as React from "react"; import { EditorContent, type Editor } from "@tiptap/react"; -import { ArrowUp } from "lucide-react"; +import { Plus } from "lucide-react"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; @@ -35,11 +35,14 @@ export function ForumComposerCompactLayout({ diff --git a/desktop/src/features/forum/ui/useCompactComposerInteractions.ts b/desktop/src/features/forum/ui/useCompactComposerInteractions.ts new file mode 100644 index 0000000000..51d0fc46a1 --- /dev/null +++ b/desktop/src/features/forum/ui/useCompactComposerInteractions.ts @@ -0,0 +1,40 @@ +import * as React from "react"; + +type UseCompactComposerInteractionsArgs = { + compact: boolean; + onExpand: () => void; + onPaperclip: () => Promise; +}; + +export function useCompactComposerInteractions({ + compact, + onExpand, + onPaperclip, +}: UseCompactComposerInteractionsArgs) { + const isMediaPickerActiveRef = React.useRef(false); + const isToolbarInteractionActiveRef = React.useRef(false); + + const handlePaperclipClick = React.useCallback(() => { + if (compact) onExpand(); + isMediaPickerActiveRef.current = true; + void onPaperclip().finally(() => { + isMediaPickerActiveRef.current = false; + }); + }, [compact, onExpand, onPaperclip]); + + const handleToolbarMouseDown = React.useCallback(() => { + if (compact) onExpand(); + isToolbarInteractionActiveRef.current = true; + window.setTimeout(() => { + isToolbarInteractionActiveRef.current = false; + }, 0); + }, [compact, onExpand]); + + const shouldIgnoreBlur = React.useCallback( + () => + isMediaPickerActiveRef.current || isToolbarInteractionActiveRef.current, + [], + ); + + return { handlePaperclipClick, handleToolbarMouseDown, shouldIgnoreBlur }; +} diff --git a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx index 9c84c8768a..59920791b7 100644 --- a/desktop/src/features/messages/ui/MessageComposerToolbar.tsx +++ b/desktop/src/features/messages/ui/MessageComposerToolbar.tsx @@ -90,6 +90,7 @@ export const MessageComposerToolbar = React.memo( aria-pressed={isFormattingOpen} disabled={composerDisabled} onClick={() => onFormattingToggle(!isFormattingOpen)} + onMouseDown={onCaptureSelection} className={cn( "inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors", "hover:bg-muted hover:text-foreground", @@ -120,6 +121,7 @@ export const MessageComposerToolbar = React.memo( aria-label="Close formatting" disabled={composerDisabled} onClick={() => onFormattingToggle(false)} + onMouseDown={onCaptureSelection} size="icon" type="button" variant="ghost" @@ -180,6 +182,7 @@ export const MessageComposerToolbar = React.memo( aria-label="Attach image" disabled={composerDisabled || isUploading} onClick={onPaperclip} + onMouseDown={onCaptureSelection} size="icon" type="button" variant="ghost" @@ -214,6 +217,7 @@ export const MessageComposerToolbar = React.memo( aria-pressed={isFormattingOpen} disabled={composerDisabled} onClick={() => onFormattingToggle(!isFormattingOpen)} + onMouseDown={onCaptureSelection} className={cn( "inline-flex h-8 min-w-8 items-center justify-center rounded-md px-2 text-sm font-medium transition-colors", "hover:bg-muted hover:text-foreground", diff --git a/desktop/src/features/pulse/ui/AgentActivityCard.tsx b/desktop/src/features/pulse/ui/AgentActivityCard.tsx index 95465638d9..7f07b6b0f4 100644 --- a/desktop/src/features/pulse/ui/AgentActivityCard.tsx +++ b/desktop/src/features/pulse/ui/AgentActivityCard.tsx @@ -51,7 +51,7 @@ export function AgentActivityCard({ const summaryNote = group.notes[0]; return ( -
+
{/* Header */}
diff --git a/desktop/src/features/pulse/ui/NoteCard.tsx b/desktop/src/features/pulse/ui/NoteCard.tsx index abb04e58b4..1ad34dc446 100644 --- a/desktop/src/features/pulse/ui/NoteCard.tsx +++ b/desktop/src/features/pulse/ui/NoteCard.tsx @@ -1,26 +1,31 @@ import { + ALargeSmall, + AtSign, + Bookmark, Bot, MessageCircle, - Share2, + PenSquare, + Paperclip, SmilePlus, - UserMinus, - UserPlus, + SquareArrowOutUpRight, + ThumbsUp, } from "lucide-react"; +import * as React from "react"; import type { UserNote } from "@/shared/api/socialTypes"; import type { UserProfileSummary } from "@/shared/api/types"; -import { Button } from "@/shared/ui/button"; import { Markdown } from "@/shared/ui/markdown"; import { UserAvatar } from "@/shared/ui/UserAvatar"; type NoteCardProps = { note: UserNote; profile?: UserProfileSummary | null; + currentUserDisplayName?: string; + currentUserProfile?: UserProfileSummary | null; isAgent?: boolean; isOwnNote: boolean; isFollowing: boolean; onFollow?: (pubkey: string) => void; - onReact?: (note: UserNote) => void; onReply?: (note: UserNote) => void; onShare?: (note: UserNote) => void; onUnfollow?: (pubkey: string) => void; @@ -44,20 +49,43 @@ function formatRelativeTime(unixSeconds: number): string { export function NoteCard({ note, profile, + currentUserDisplayName = "You", + currentUserProfile, isAgent, isOwnNote, isFollowing, onFollow, - onReact, onReply, onShare, onUnfollow, }: NoteCardProps) { const displayName = profile?.displayName ?? `${note.pubkey.slice(0, 8)}...`; const avatarUrl = profile?.avatarUrl ?? null; + const [isUpvoted, setIsUpvoted] = React.useState(false); + const [isBookmarked, setIsBookmarked] = React.useState(false); + const [isReplyComposerOpen, setIsReplyComposerOpen] = React.useState(false); + const [replyDraft, setReplyDraft] = React.useState(""); + const replyInputRef = React.useRef(null); + const actionButtonClass = + "inline-flex min-w-7 items-center gap-1.5 text-muted-foreground/60 transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"; + const activeActionClass = "text-foreground"; + const countPlaceholder = ; + const currentUserAvatarUrl = currentUserProfile?.avatarUrl ?? null; + + React.useEffect(() => { + if (!isReplyComposerOpen) return; + replyInputRef.current?.focus(); + }, [isReplyComposerOpen]); + + const handleReplySubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (replyDraft.trim().length === 0) return; + setReplyDraft(""); + setIsReplyComposerOpen(false); + }; return ( -
+
-
+
{displayName} @@ -87,82 +115,159 @@ export function NoteCard({ {formatRelativeTime(note.createdAt)} - {!isOwnNote ? ( -
- {isFollowing ? ( - - ) : ( - - )} -
- ) : null}
-
+
+
+ + + + + {!isOwnNote ? ( + isFollowing ? ( + + ) : ( + + ) + ) : null} +
- - - {!isOwnNote ? ( - isFollowing ? ( - - ) : ( - - ) - ) : null}
+ {isReplyComposerOpen ? ( +
+ +
+