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