Barazo default frontend barazo.forum
at main 274 lines 8.8 kB view raw
1/** 2 * ReplyComposer - Sticky bottom bar for replying to topics and replies. 3 * Sticks to viewport bottom while scrolling; settles into flow at page end 4 * so the footer remains accessible. 5 * Collapse/expand states, reply targeting with quote banner. 6 * Supports keyboard shortcuts: `r` to open (via imperative ref), `Escape` to collapse. 7 */ 8 9'use client' 10 11import { useState, useCallback, useRef, useEffect, useImperativeHandle, forwardRef } from 'react' 12import { PaperPlaneRight, X, Lock } from '@phosphor-icons/react' 13import { useAuth } from '@/hooks/use-auth' 14import { useOnboardingContext } from '@/context/onboarding-context' 15import { useToast } from '@/hooks/use-toast' 16import { ApiError, createReply } from '@/lib/api/client' 17import { MarkdownEditor } from '@/components/markdown-editor' 18import { cn } from '@/lib/utils' 19 20export interface ReplyTarget { 21 uri: string 22 cid: string 23 authorHandle: string 24 snippet: string 25} 26 27export interface ReplyComposerHandle { 28 expand: () => void 29} 30 31interface ReplyComposerProps { 32 topicUri: string 33 topicCid: string 34 communityDid: string 35 onReplyCreated: () => void 36 replyTarget?: ReplyTarget | null 37 onClearReplyTarget?: () => void 38 initialContent?: string 39 isLocked?: boolean 40 className?: string 41} 42 43export const ReplyComposer = forwardRef<ReplyComposerHandle, ReplyComposerProps>( 44 function ReplyComposer( 45 { 46 topicUri, 47 topicCid: _topicCid, 48 communityDid: _communityDid, 49 onReplyCreated, 50 replyTarget, 51 onClearReplyTarget, 52 initialContent = '', 53 isLocked = false, 54 className, 55 }, 56 ref 57 ) { 58 const { getAccessToken } = useAuth() 59 const { ensureOnboarded } = useOnboardingContext() 60 const { toast } = useToast() 61 const [isExpanded, setIsExpanded] = useState(false) 62 const [content, setContent] = useState(initialContent) 63 const [submitting, setSubmitting] = useState(false) 64 const composerRef = useRef<HTMLDivElement>(null) 65 const previousFocusRef = useRef<HTMLElement | null>(null) 66 67 // Expose expand() to parent via ref 68 useImperativeHandle(ref, () => ({ 69 expand: () => { 70 setIsExpanded(true) 71 }, 72 })) 73 74 // Sync initialContent when it changes (e.g., select-to-quote) 75 useEffect(() => { 76 if (initialContent) { 77 setContent(initialContent) 78 setIsExpanded(true) 79 } 80 }, [initialContent]) 81 82 // Focus textarea when expanding, save previous focus for restoration 83 useEffect(() => { 84 if (isExpanded) { 85 previousFocusRef.current = document.activeElement as HTMLElement | null 86 requestAnimationFrame(() => { 87 const textarea = composerRef.current?.querySelector('textarea') 88 textarea?.focus() 89 }) 90 } 91 }, [isExpanded]) 92 93 // Auto-expand when reply target is set 94 useEffect(() => { 95 if (replyTarget) { 96 setIsExpanded(true) 97 } 98 }, [replyTarget]) 99 100 // Escape key collapses the composer 101 useEffect(() => { 102 if (!isExpanded) return 103 104 const handleKeyDown = (e: KeyboardEvent) => { 105 if (e.key === 'Escape') { 106 e.preventDefault() 107 setIsExpanded(false) 108 // Restore focus to the element that was focused before expanding 109 requestAnimationFrame(() => { 110 previousFocusRef.current?.focus() 111 }) 112 } 113 } 114 document.addEventListener('keydown', handleKeyDown) 115 return () => document.removeEventListener('keydown', handleKeyDown) 116 }, [isExpanded]) 117 118 const handleExpand = useCallback(() => { 119 setIsExpanded(true) 120 }, []) 121 122 const handleCollapse = useCallback(() => { 123 setIsExpanded(false) 124 // Restore focus to the element that was focused before expanding 125 requestAnimationFrame(() => { 126 previousFocusRef.current?.focus() 127 }) 128 }, []) 129 130 const handleSubmit = useCallback(async () => { 131 if (!ensureOnboarded()) return 132 const trimmed = content.trim() 133 if (!trimmed) return 134 135 setSubmitting(true) 136 try { 137 const accessToken = getAccessToken() ?? '' 138 const result = await createReply( 139 topicUri, 140 { 141 content: trimmed, 142 parentUri: replyTarget?.uri, 143 }, 144 accessToken 145 ) 146 147 setContent('') 148 setIsExpanded(false) 149 150 if (result.moderationStatus === 'held') { 151 toast({ 152 title: 'Reply submitted', 153 description: 'Your reply is pending moderator review and will appear once approved.', 154 }) 155 } else { 156 onReplyCreated() 157 toast({ title: 'Reply posted' }) 158 } 159 } catch (err) { 160 if (err instanceof ApiError && err.errorCode === 'Onboarding required') { 161 ensureOnboarded() 162 } else { 163 const message = err instanceof Error ? err.message : 'Failed to post reply' 164 toast({ title: 'Error', description: message, variant: 'destructive' }) 165 } 166 } finally { 167 setSubmitting(false) 168 } 169 }, [content, topicUri, replyTarget, getAccessToken, ensureOnboarded, onReplyCreated, toast]) 170 171 if (isLocked) { 172 return ( 173 <div 174 className={cn( 175 'sticky bottom-0 z-30 border-t border-border bg-muted/80 backdrop-blur', 176 className 177 )} 178 > 179 <div className="container flex items-center justify-center gap-2 py-3 text-sm text-muted-foreground"> 180 <Lock className="h-4 w-4" weight="bold" aria-hidden="true" /> 181 This topic is locked. New replies are not accepted. 182 </div> 183 </div> 184 ) 185 } 186 187 if (!isExpanded) { 188 return ( 189 <div 190 className={cn( 191 'sticky bottom-0 z-30 border-t border-border bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60', 192 className 193 )} 194 > 195 <div className="container"> 196 <button 197 type="button" 198 onClick={handleExpand} 199 className="flex w-full items-center justify-between py-3 text-sm text-muted-foreground transition-colors hover:text-foreground" 200 > 201 <span>Write a reply...</span> 202 <PaperPlaneRight className="h-4 w-4" weight="bold" aria-hidden="true" /> 203 </button> 204 </div> 205 </div> 206 ) 207 } 208 209 return ( 210 <div 211 ref={composerRef} 212 className={cn( 213 'sticky bottom-0 z-30 border-t border-border bg-background shadow-lg', 214 className 215 )} 216 > 217 <div className="container space-y-2 py-3"> 218 {/* Reply target banner */} 219 {replyTarget && ( 220 <div className="flex items-start justify-between rounded-md bg-muted px-3 py-2 text-sm"> 221 <div className="min-w-0"> 222 <p className="font-medium text-foreground"> 223 Replying to @{replyTarget.authorHandle} 224 </p> 225 <p className="truncate text-muted-foreground">{replyTarget.snippet}</p> 226 </div> 227 <button 228 type="button" 229 onClick={onClearReplyTarget} 230 aria-label="Dismiss reply target" 231 className="ml-2 shrink-0 rounded p-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 232 > 233 <X className="h-4 w-4" weight="bold" aria-hidden="true" /> 234 </button> 235 </div> 236 )} 237 238 {/* Editor */} 239 <MarkdownEditor 240 value={content} 241 onChange={setContent} 242 id="reply-content" 243 label="Reply" 244 placeholder="Write your reply..." 245 className="[&_label]:sr-only [&_textarea]:min-h-[120px] [&_textarea]:max-h-[40vh]" 246 /> 247 248 {/* Actions */} 249 <div className="flex items-center justify-end gap-2"> 250 <button 251 type="button" 252 onClick={handleCollapse} 253 className="rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 254 > 255 Cancel 256 </button> 257 <button 258 type="button" 259 onClick={handleSubmit} 260 disabled={submitting || !content.trim()} 261 className={cn( 262 'rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground transition-colors', 263 'hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 264 'disabled:cursor-not-allowed disabled:opacity-50' 265 )} 266 > 267 {submitting ? 'Posting...' : 'Reply'} 268 </button> 269 </div> 270 </div> 271 </div> 272 ) 273 } 274)