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 */}
 109        {writeRelays.length > 0 && (
 110          <div className="space-y-1">
 111            <button
 112              onClick={() => setShowRelays(!showRelays)}
 113              className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
 114            >
 115              {showRelays ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
 116              <span>
 117                {t('Relays')} ({selectedRelays.size}/{writeRelays.length})
 118              </span>
 119            </button>
 120            {showRelays && (
 121              <div className="flex flex-wrap gap-1">
 122                {writeRelays.map((url) => (
 123                  <button
 124                    key={url}
 125                    onClick={() => toggleRelay(url)}
 126                    className={cn(
 127                      'text-xs px-2 py-0.5 rounded-full border transition-colors',
 128                      selectedRelays.has(url)
 129                        ? 'bg-primary text-primary-foreground border-primary'
 130                        : 'bg-muted text-muted-foreground border-muted hover:border-primary/50'
 131                    )}
 132                    title={url}
 133                  >
 134                    {formatRelayUrl(url)}
 135                  </button>
 136                ))}
 137              </div>
 138            )}
 139          </div>
 140        )}
 141  
 142        <div className="flex items-end gap-2">
 143          <Textarea
 144            ref={textareaRef}
 145            value={message}
 146            onChange={(e) => {
 147              setMessage(e.target.value)
 148              if (error) setError(null)
 149              resizeTextarea()
 150            }}
 151            onKeyDown={handleKeyDown}
 152            placeholder={t('Type a message...')}
 153            className="min-h-[40px] resize-none overflow-hidden"
 154            disabled={isSending || !currentConversation}
 155          />
 156          <Button
 157            onClick={handleSend}
 158            disabled={!message.trim() || isSending || !currentConversation}
 159            size="icon"
 160            className="flex-shrink-0"
 161          >
 162            {isSending ? (
 163              <Loader2 className="size-4 animate-spin" />
 164            ) : (
 165              <Send className="size-4" />
 166            )}
 167          </Button>
 168        </div>
 169      </div>
 170    )
 171  }
 172