MessageComposer.tsx raw

   1  import { cn, isTouchDevice } from '@/lib/utils'
   2  import { useDM } from '@/providers/DMProvider'
   3  import { useNostr } from '@/providers/NostrProvider'
   4  import { AlertCircle, ChevronDown, ChevronUp, Loader2, Send } from 'lucide-react'
   5  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
   6  import { useTranslation } from 'react-i18next'
   7  import { Button } from '../ui/button'
   8  import { Textarea } from '../ui/textarea'
   9  
  10  export default function MessageComposer() {
  11    const { t } = useTranslation()
  12    const { sendMessage, currentConversation } = useDM()
  13    const { relayList } = useNostr()
  14    const [message, setMessage] = useState('')
  15    const [isSending, setIsSending] = useState(false)
  16    const [error, setError] = useState<string | null>(null)
  17    const [showRelays, setShowRelays] = useState(false)
  18    const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set())
  19    const textareaRef = useRef<HTMLTextAreaElement>(null)
  20  
  21    // Get user's write relays
  22    const writeRelays = useMemo(() => relayList?.write || [], [relayList])
  23  
  24    // Initialize selected relays when write relays change
  25    useEffect(() => {
  26      if (writeRelays.length > 0 && selectedRelays.size === 0) {
  27        setSelectedRelays(new Set(writeRelays))
  28      }
  29    }, [writeRelays])
  30  
  31    // Auto-focus input when conversation changes (desktop only to avoid triggering mobile keyboard)
  32    useEffect(() => {
  33      if (currentConversation && !isTouchDevice()) {
  34        const timer = setTimeout(() => textareaRef.current?.focus(), 100)
  35        return () => clearTimeout(timer)
  36      }
  37    }, [currentConversation])
  38  
  39    // Auto-resize textarea to fit content, capped at 50% viewport height
  40    const resizeTextarea = useCallback(() => {
  41      const textarea = textareaRef.current
  42      if (!textarea) return
  43      textarea.style.height = 'auto'
  44      const maxHeight = window.innerHeight * 0.5
  45      textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`
  46      textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
  47    }, [])
  48  
  49    const toggleRelay = (url: string) => {
  50      setSelectedRelays((prev) => {
  51        const next = new Set(prev)
  52        if (next.has(url)) {
  53          // Don't allow deselecting all relays
  54          if (next.size > 1) {
  55            next.delete(url)
  56          }
  57        } else {
  58          next.add(url)
  59        }
  60        return next
  61      })
  62    }
  63  
  64    const handleSend = async () => {
  65      if (!message.trim() || !currentConversation || isSending) return
  66  
  67      setIsSending(true)
  68      setError(null)
  69      try {
  70        const relaysToUse = Array.from(selectedRelays)
  71        await sendMessage(message.trim(), relaysToUse.length > 0 ? relaysToUse : undefined)
  72        setMessage('')
  73        // Reset textarea height and return focus after sending
  74        if (textareaRef.current) {
  75          textareaRef.current.style.height = 'auto'
  76          textareaRef.current.style.overflowY = 'hidden'
  77          textareaRef.current.focus()
  78        }
  79      } catch (err) {
  80        console.error('Failed to send message:', err)
  81        setError(err instanceof Error ? err.message : t('Failed to send message'))
  82      } finally {
  83        setIsSending(false)
  84      }
  85    }
  86  
  87    const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
  88      if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
  89        e.preventDefault()
  90        handleSend()
  91      }
  92    }
  93  
  94    // Format relay URL for display
  95    const formatRelayUrl = (url: string) => {
  96      return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
  97    }
  98  
  99    return (
 100      <div className="p-3 space-y-2">
 101        {error && (
 102          <div className="flex items-center gap-2 text-destructive text-xs">
 103            <AlertCircle className="size-3 flex-shrink-0" />
 104            <span>{error}</span>
 105          </div>
 106        )}
 107  
 108        {/* Relay selector + expiration */}
 109        <div className="flex items-center gap-3">
 110          {writeRelays.length > 0 && (
 111            <div className="space-y-1">
 112              <button
 113                onClick={() => setShowRelays(!showRelays)}
 114                className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
 115              >
 116                {showRelays ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
 117                <span>
 118                  {t('Relays')} ({selectedRelays.size}/{writeRelays.length})
 119                </span>
 120              </button>
 121            </div>
 122          )}
 123        </div>
 124        {showRelays && writeRelays.length > 0 && (
 125          <div className="flex flex-wrap gap-1">
 126            {writeRelays.map((url) => (
 127              <button
 128                key={url}
 129                onClick={() => toggleRelay(url)}
 130                className={cn(
 131                  'text-xs px-2 py-0.5 rounded-full border transition-colors',
 132                  selectedRelays.has(url)
 133                    ? 'bg-primary text-primary-foreground border-primary'
 134                    : 'bg-muted text-muted-foreground border-muted hover:border-primary/50'
 135                )}
 136                title={url}
 137              >
 138                {formatRelayUrl(url)}
 139              </button>
 140            ))}
 141          </div>
 142        )}
 143        <div className="flex items-end gap-2">
 144          <Textarea
 145            ref={textareaRef}
 146            value={message}
 147            onChange={(e) => {
 148              setMessage(e.target.value)
 149              if (error) setError(null)
 150              resizeTextarea()
 151            }}
 152            onKeyDown={handleKeyDown}
 153            placeholder={t('Type a message...')}
 154            className="min-h-[40px] resize-none overflow-hidden"
 155            disabled={isSending || !currentConversation}
 156          />
 157          <Button
 158            onClick={handleSend}
 159            disabled={!message.trim() || isSending || !currentConversation}
 160            size="icon"
 161            className="flex-shrink-0"
 162          >
 163            {isSending ? (
 164              <Loader2 className="size-4 animate-spin" />
 165            ) : (
 166              <Send className="size-4" />
 167            )}
 168          </Button>
 169        </div>
 170      </div>
 171    )
 172  }
 173