/** * ReplyComposer - Sticky bottom bar for replying to topics and replies. * Sticks to viewport bottom while scrolling; settles into flow at page end * so the footer remains accessible. * Collapse/expand states, reply targeting with quote banner. * Supports keyboard shortcuts: `r` to open (via imperative ref), `Escape` to collapse. */ 'use client' import { useState, useCallback, useRef, useEffect, useImperativeHandle, forwardRef } from 'react' import { PaperPlaneRight, X, Lock } from '@phosphor-icons/react' import { useAuth } from '@/hooks/use-auth' import { useOnboardingContext } from '@/context/onboarding-context' import { useToast } from '@/hooks/use-toast' import { ApiError, createReply } from '@/lib/api/client' import { MarkdownEditor } from '@/components/markdown-editor' import { cn } from '@/lib/utils' export interface ReplyTarget { uri: string cid: string authorHandle: string snippet: string } export interface ReplyComposerHandle { expand: () => void } interface ReplyComposerProps { topicUri: string topicCid: string communityDid: string onReplyCreated: () => void replyTarget?: ReplyTarget | null onClearReplyTarget?: () => void initialContent?: string isLocked?: boolean className?: string } export const ReplyComposer = forwardRef( function ReplyComposer( { topicUri, topicCid: _topicCid, communityDid: _communityDid, onReplyCreated, replyTarget, onClearReplyTarget, initialContent = '', isLocked = false, className, }, ref ) { const { getAccessToken } = useAuth() const { ensureOnboarded } = useOnboardingContext() const { toast } = useToast() const [isExpanded, setIsExpanded] = useState(false) const [content, setContent] = useState(initialContent) const [submitting, setSubmitting] = useState(false) const composerRef = useRef(null) const previousFocusRef = useRef(null) // Expose expand() to parent via ref useImperativeHandle(ref, () => ({ expand: () => { setIsExpanded(true) }, })) // Sync initialContent when it changes (e.g., select-to-quote) useEffect(() => { if (initialContent) { setContent(initialContent) setIsExpanded(true) } }, [initialContent]) // Focus textarea when expanding, save previous focus for restoration useEffect(() => { if (isExpanded) { previousFocusRef.current = document.activeElement as HTMLElement | null requestAnimationFrame(() => { const textarea = composerRef.current?.querySelector('textarea') textarea?.focus() }) } }, [isExpanded]) // Auto-expand when reply target is set useEffect(() => { if (replyTarget) { setIsExpanded(true) } }, [replyTarget]) // Escape key collapses the composer useEffect(() => { if (!isExpanded) return const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault() setIsExpanded(false) // Restore focus to the element that was focused before expanding requestAnimationFrame(() => { previousFocusRef.current?.focus() }) } } document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) }, [isExpanded]) const handleExpand = useCallback(() => { setIsExpanded(true) }, []) const handleCollapse = useCallback(() => { setIsExpanded(false) // Restore focus to the element that was focused before expanding requestAnimationFrame(() => { previousFocusRef.current?.focus() }) }, []) const handleSubmit = useCallback(async () => { if (!ensureOnboarded()) return const trimmed = content.trim() if (!trimmed) return setSubmitting(true) try { const accessToken = getAccessToken() ?? '' const result = await createReply( topicUri, { content: trimmed, parentUri: replyTarget?.uri, }, accessToken ) setContent('') setIsExpanded(false) if (result.moderationStatus === 'held') { toast({ title: 'Reply submitted', description: 'Your reply is pending moderator review and will appear once approved.', }) } else { onReplyCreated() toast({ title: 'Reply posted' }) } } catch (err) { if (err instanceof ApiError && err.errorCode === 'Onboarding required') { ensureOnboarded() } else { const message = err instanceof Error ? err.message : 'Failed to post reply' toast({ title: 'Error', description: message, variant: 'destructive' }) } } finally { setSubmitting(false) } }, [content, topicUri, replyTarget, getAccessToken, ensureOnboarded, onReplyCreated, toast]) if (isLocked) { return (
) } if (!isExpanded) { return (
) } return (
{/* Reply target banner */} {replyTarget && (

Replying to @{replyTarget.authorHandle}

{replyTarget.snippet}

)} {/* Editor */} {/* Actions */}
) } )