Barazo default frontend
barazo.forum
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)