PostContent.tsx raw
1 import Note from '@/components/Note'
2 import { Button } from '@/components/ui/button'
3 import { ScrollArea } from '@/components/ui/scroll-area'
4 import {
5 createCommentDraftEvent,
6 createHighlightDraftEvent,
7 createPollDraftEvent,
8 createShortTextNoteDraftEvent,
9 deleteDraftEventCache
10 } from '@/lib/draft-event'
11 import { cn, isTouchDevice } from '@/lib/utils'
12 import { useNostr } from '@/providers/NostrProvider'
13 import { useScreenSize } from '@/providers/ScreenSizeProvider'
14 import postEditorCache from '@/services/post-editor-cache.service'
15 import threadService from '@/services/thread.service'
16 import { TPollCreateData } from '@/types'
17 import { rewriteText } from '@/services/llm.service'
18 import storage from '@/services/local-storage.service'
19 import { ImageUp, ListTodo, LoaderCircle, Settings, Smile, Sparkles, X } from 'lucide-react'
20 import { Event, kinds } from 'nostr-tools'
21 import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
22 import { useTranslation } from 'react-i18next'
23 import { toast } from 'sonner'
24 import EmojiPickerDialog from '../EmojiPickerDialog'
25 import Mentions from './Mentions'
26 import PollEditor from './PollEditor'
27 import PostOptions from './PostOptions'
28 import PostTextarea, { TPostTextareaHandle } from './PostTextarea'
29 import Uploader from './Uploader'
30
31 export type TPostContentHandle = {
32 reset: () => void
33 }
34
35 const PostContent = forwardRef<
36 TPostContentHandle,
37 {
38 defaultContent?: string
39 parentStuff?: Event | string
40 close: () => void
41 highlightedText?: string
42 }
43 >(({ defaultContent = '', parentStuff, close, highlightedText }, ref) => {
44 const { t } = useTranslation()
45 const { pubkey, publish, checkLogin } = useNostr()
46 const [text, setText] = useState('')
47 const textareaRef = useRef<TPostTextareaHandle>(null)
48 const [posting, setPosting] = useState(false)
49 const [uploadProgresses, setUploadProgresses] = useState<
50 { file: File; progress: number; cancel: () => void }[]
51 >([])
52 const parentEvent = useMemo(
53 () => (parentStuff && typeof parentStuff !== 'string' ? parentStuff : undefined),
54 [parentStuff]
55 )
56 const { isSmallScreen } = useScreenSize()
57 const [showMoreOptions, setShowMoreOptions] = useState(false)
58 const [addClientTag, setAddClientTag] = useState(false)
59 const [mentions, setMentions] = useState<string[]>([])
60 const [isNsfw, setIsNsfw] = useState(false)
61 const [isPoll, setIsPoll] = useState(false)
62 const [pollCreateData, setPollCreateData] = useState<TPollCreateData>({
63 isMultipleChoice: false,
64 options: ['', ''],
65 endsAt: undefined,
66 relays: []
67 })
68 const [minPow, setMinPow] = useState(0)
69 const [rewriting, setRewriting] = useState(false)
70 const llmConfig = useMemo(() => (pubkey ? storage.getLlmConfig(pubkey) : null), [pubkey])
71 const llmConfigured = !!(llmConfig?.apiKey && llmConfig?.systemPrompt)
72 const isFirstRender = useRef(true)
73 const canPost = useMemo(() => {
74 return (
75 !!pubkey &&
76 (!!text || !!highlightedText) &&
77 !posting &&
78 !uploadProgresses.length &&
79 (!isPoll || pollCreateData.options.filter((option) => !!option.trim()).length >= 2)
80 )
81 }, [pubkey, text, highlightedText, posting, uploadProgresses, isPoll, pollCreateData])
82
83 useImperativeHandle(ref, () => ({
84 reset: () => {
85 textareaRef.current?.clear()
86 setText('')
87 setMentions([])
88 setIsNsfw(false)
89 setIsPoll(false)
90 setPollCreateData({
91 isMultipleChoice: false,
92 options: ['', ''],
93 endsAt: undefined,
94 relays: []
95 })
96 setAddClientTag(false)
97 setMinPow(0)
98 }
99 }))
100
101 useEffect(() => {
102 if (isFirstRender.current) {
103 isFirstRender.current = false
104 const cachedSettings = postEditorCache.getPostSettingsCache({
105 defaultContent,
106 parentStuff
107 })
108 if (cachedSettings) {
109 setIsNsfw(cachedSettings.isNsfw ?? false)
110 setIsPoll(cachedSettings.isPoll ?? false)
111 setPollCreateData(
112 cachedSettings.pollCreateData ?? {
113 isMultipleChoice: false,
114 options: ['', ''],
115 endsAt: undefined,
116 relays: []
117 }
118 )
119 setAddClientTag(cachedSettings.addClientTag ?? false)
120 }
121 return
122 }
123 postEditorCache.setPostSettingsCache(
124 { defaultContent, parentStuff },
125 {
126 isNsfw,
127 isPoll,
128 pollCreateData,
129 addClientTag
130 }
131 )
132 }, [defaultContent, parentStuff, isNsfw, isPoll, pollCreateData, addClientTag])
133
134 const postingRef = useRef(false)
135
136 const post = async (e?: React.MouseEvent) => {
137 e?.stopPropagation()
138 checkLogin(async () => {
139 if (!canPost || !pubkey || postingRef.current) return
140
141 postingRef.current = true
142 setPosting(true)
143 try {
144 // Auto-rewrite if enabled
145 let finalText = text
146 if (llmConfig?.autoRewrite && llmConfig.apiKey && llmConfig.systemPrompt && text.trim()) {
147 try {
148 finalText = await rewriteText(llmConfig.apiKey, llmConfig.systemPrompt, text, llmConfig.model)
149 textareaRef.current?.replaceText(finalText)
150 } catch (error) {
151 toast.error(
152 `${t('Auto-rewrite failed, posting original text')}: ${error instanceof Error ? error.message : String(error)}`,
153 { duration: 5000 }
154 )
155 }
156 }
157
158 const draftEvent = await createDraftEvent({
159 parentStuff,
160 highlightedText,
161 text: finalText,
162 mentions,
163 isPoll,
164 pollCreateData,
165 pubkey,
166 addClientTag,
167 isProtectedEvent: false,
168 isNsfw
169 })
170
171 // For external content comments, relay selection happens in determineTargetRelays
172 // based on relay hints in tags - no need to specify additional relays here
173 const additionalRelayUrls = isPoll ? pollCreateData.relays : []
174
175 const newEvent = await publish(draftEvent, {
176 additionalRelayUrls,
177 minPow
178 })
179 postEditorCache.clearPostCache({ defaultContent, parentStuff })
180 deleteDraftEventCache(draftEvent)
181 threadService.addRepliesToThread([newEvent])
182 toast.success(t('Post successful'), { duration: 2000 })
183 close()
184
185 // Relay failures are tracked silently in relay-stats.service
186 } catch (error) {
187 const errors = error instanceof AggregateError ? error.errors : [error]
188 errors.forEach((err) => {
189 toast.error(
190 `${t('Failed to post')}: ${err instanceof Error ? err.message : String(err)}`,
191 { duration: 10_000 }
192 )
193 console.error(err)
194 })
195 return
196 } finally {
197 setPosting(false)
198 postingRef.current = false
199 }
200 })
201 }
202
203 const handlePollToggle = () => {
204 if (parentStuff) return
205
206 setIsPoll((prev) => !prev)
207 }
208
209 const handleRewrite = async () => {
210 if (!llmConfig?.apiKey || !llmConfig?.systemPrompt || !text.trim() || rewriting) return
211 setRewriting(true)
212 try {
213 const rewritten = await rewriteText(llmConfig.apiKey, llmConfig.systemPrompt, text, llmConfig.model)
214 textareaRef.current?.replaceText(rewritten)
215 } catch (error) {
216 toast.error(
217 `${t('Rewrite failed')}: ${error instanceof Error ? error.message : String(error)}`,
218 { duration: 10_000 }
219 )
220 } finally {
221 setRewriting(false)
222 }
223 }
224
225 const handleUploadStart = (file: File, cancel: () => void) => {
226 setUploadProgresses((prev) => [...prev, { file, progress: 0, cancel }])
227 }
228
229 const handleUploadProgress = (file: File, progress: number) => {
230 setUploadProgresses((prev) =>
231 prev.map((item) => (item.file === file ? { ...item, progress } : item))
232 )
233 }
234
235 const handleUploadEnd = (file: File) => {
236 setUploadProgresses((prev) => prev.filter((item) => item.file !== file))
237 }
238
239 return (
240 <div className={cn('space-y-2', isSmallScreen ? 'flex flex-col h-full' : 'flex flex-col')}>
241 <div className="flex items-center justify-between shrink-0">
242 <div className="flex gap-2 items-center">
243 <Uploader
244 responsive
245 onUploadSuccess={({ primaryUrl }) => {
246 textareaRef.current?.appendText(primaryUrl, true)
247 }}
248 onUploadStart={handleUploadStart}
249 onUploadEnd={handleUploadEnd}
250 onProgress={handleUploadProgress}
251 accept="image/*,video/*,audio/*"
252 >
253 <Button variant="ghost" size="icon">
254 <ImageUp />
255 </Button>
256 </Uploader>
257 {/* I'm not sure why, but after triggering the virtual keyboard,
258 opening the emoji picker drawer causes an issue,
259 the emoji I tap isn't the one that gets inserted. */}
260 {!isTouchDevice() && (
261 <EmojiPickerDialog
262 onEmojiClick={(emoji) => {
263 if (!emoji) return
264 textareaRef.current?.insertEmoji(emoji)
265 }}
266 >
267 <Button variant="ghost" size="icon">
268 <Smile />
269 </Button>
270 </EmojiPickerDialog>
271 )}
272 {!parentStuff && (
273 <Button
274 variant="ghost"
275 size="icon"
276 title={t('Create Poll')}
277 className={isPoll ? 'bg-accent' : ''}
278 onClick={handlePollToggle}
279 >
280 <ListTodo />
281 </Button>
282 )}
283 <Button
284 variant="ghost"
285 size="icon"
286 className={showMoreOptions ? 'bg-accent' : ''}
287 onClick={() => setShowMoreOptions((pre) => !pre)}
288 >
289 <Settings />
290 </Button>
291 {llmConfigured && !llmConfig?.autoRewrite && (
292 <Button
293 variant="ghost"
294 size="icon"
295 title={t('Rewrite with AI')}
296 disabled={!text.trim() || rewriting || posting}
297 onClick={handleRewrite}
298 >
299 {rewriting ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
300 </Button>
301 )}
302 </div>
303 <div className="flex gap-2 items-center">
304 <Mentions
305 content={text}
306 parentEvent={parentEvent}
307 mentions={mentions}
308 setMentions={setMentions}
309 />
310 <div className="flex gap-2 items-center max-sm:hidden">
311 <Button
312 variant="secondary"
313 onClick={(e) => {
314 e.stopPropagation()
315 close()
316 }}
317 >
318 {t('Cancel')}
319 </Button>
320 <Button type="submit" disabled={!canPost} onClick={post}>
321 {posting && <LoaderCircle className="animate-spin" />}
322 {parentStuff ? (highlightedText ? t('Publish Highlight') : t('Reply')) : t('Post')}
323 </Button>
324 </div>
325 </div>
326 </div>
327 <div className="shrink-0">
328 <PostOptions
329 posting={posting}
330 show={showMoreOptions}
331 addClientTag={addClientTag}
332 setAddClientTag={setAddClientTag}
333 isNsfw={isNsfw}
334 setIsNsfw={setIsNsfw}
335 minPow={minPow}
336 setMinPow={setMinPow}
337 />
338 </div>
339 {parentEvent && (
340 <ScrollArea className="flex max-h-48 flex-col overflow-y-auto rounded-lg border bg-muted/40 shrink-0">
341 <div className="p-2 sm:p-3 pointer-events-none">
342 {highlightedText ? (
343 <div className="flex gap-4">
344 <div className="w-1 flex-shrink-0 my-1 bg-primary/60 rounded-md" />
345 <div className="italic whitespace-pre-line">{highlightedText}</div>
346 </div>
347 ) : (
348 <Note size="small" event={parentEvent} hideParentNotePreview />
349 )}
350 </div>
351 </ScrollArea>
352 )}
353 <PostTextarea
354 ref={textareaRef}
355 text={text}
356 setText={setText}
357 defaultContent={defaultContent}
358 parentStuff={parentStuff}
359 onSubmit={() => post()}
360 className={cn(isPoll ? 'min-h-20' : 'min-h-52', isSmallScreen && 'flex-1')}
361 fillHeight={isSmallScreen}
362 onUploadStart={handleUploadStart}
363 onUploadProgress={handleUploadProgress}
364 onUploadEnd={handleUploadEnd}
365 placeholder={highlightedText ? t('Write your thoughts about this highlight...') : undefined}
366 />
367 {isPoll && (
368 <div className="shrink-0">
369 <PollEditor
370 pollCreateData={pollCreateData}
371 setPollCreateData={setPollCreateData}
372 setIsPoll={setIsPoll}
373 />
374 </div>
375 )}
376 {uploadProgresses.length > 0 &&
377 uploadProgresses.map(({ file, progress, cancel }, index) => (
378 <div key={`${file.name}-${index}`} className="mt-2 flex items-end gap-2 shrink-0">
379 <div className="min-w-0 flex-1">
380 <div className="truncate text-xs text-muted-foreground mb-1">
381 {file.name ?? t('Uploading...')}
382 </div>
383 <div className="h-0.5 w-full rounded-full bg-muted overflow-hidden">
384 <div
385 className="h-full bg-primary transition-[width] duration-200 ease-out"
386 style={{ width: `${progress}%` }}
387 />
388 </div>
389 </div>
390 <button
391 type="button"
392 onClick={() => {
393 cancel?.()
394 handleUploadEnd(file)
395 }}
396 className="text-muted-foreground hover:text-foreground"
397 title={t('Cancel')}
398 >
399 <X className="h-4 w-4" />
400 </button>
401 </div>
402 ))}
403 <div className="sm:hidden shrink-0">
404 <Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
405 {posting && <LoaderCircle className="animate-spin" />}
406 {parentStuff ? t('Reply') : t('Post')}
407 </Button>
408 </div>
409 </div>
410 )
411 })
412
413 PostContent.displayName = 'PostContent'
414 export default PostContent
415
416 async function createDraftEvent({
417 parentStuff,
418 text,
419 mentions,
420 isPoll,
421 pollCreateData,
422 pubkey,
423 addClientTag,
424 isProtectedEvent,
425 isNsfw,
426 highlightedText
427 }: {
428 parentStuff: Event | string | undefined
429 text: string
430 mentions: string[]
431 isPoll: boolean
432 pollCreateData: TPollCreateData
433 pubkey: string
434 addClientTag: boolean
435 isProtectedEvent: boolean
436 isNsfw: boolean
437 highlightedText?: string
438 }) {
439 const { parentEvent, externalContent } =
440 typeof parentStuff === 'string'
441 ? { parentEvent: undefined, externalContent: parentStuff }
442 : { parentEvent: parentStuff, externalContent: undefined }
443
444 if (highlightedText && parentEvent) {
445 return createHighlightDraftEvent(highlightedText, text, parentEvent, mentions, {
446 addClientTag,
447 protectedEvent: isProtectedEvent,
448 isNsfw
449 })
450 }
451
452 if (parentStuff && (externalContent || parentEvent?.kind !== kinds.ShortTextNote)) {
453 return await createCommentDraftEvent(text, parentStuff, mentions, {
454 addClientTag,
455 protectedEvent: isProtectedEvent,
456 isNsfw
457 })
458 }
459
460 if (isPoll) {
461 return await createPollDraftEvent(pubkey, text, mentions, pollCreateData, {
462 addClientTag,
463 isNsfw
464 })
465 }
466
467 return await createShortTextNoteDraftEvent(text, mentions, {
468 parentEvent,
469 addClientTag,
470 protectedEvent: isProtectedEvent,
471 isNsfw
472 })
473 }
474