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