ChannelView.tsx raw
1 import { useChat } from '@/providers/ChatProvider'
2 import { useNostr } from '@/providers/NostrProvider'
3 import { useFetchProfile } from '@/hooks/useFetchProfile'
4 import { isTouchDevice } from '@/lib/utils'
5 import { Pubkey } from '@/domain'
6 import {
7 Hash,
8 Loader2,
9 Send,
10 ChevronUp,
11 Shield,
12 EyeOff,
13 Ban,
14 Lock,
15 Settings2,
16 Users,
17 LogIn
18 } from 'lucide-react'
19 import { useCallback, useEffect, useRef, useState } from 'react'
20 import { Button } from '../ui/button'
21 import { Textarea } from '../ui/textarea'
22 import dayjs from 'dayjs'
23 import ChannelSettingsPanel from './ChannelSettingsPanel'
24 import ChatContent from './ChatContent'
25 import UserProfileModal from './UserProfileModal'
26 import MentionPopup from './MentionPopup'
27 import MemberListPanel from './MemberListPanel'
28
29 type TSubmitKey = 'enter' | 'ctrl+enter'
30
31 function loadSubmitKey(): TSubmitKey {
32 const v = localStorage.getItem('nirc:submitKey')
33 return v === 'enter' ? 'enter' : 'ctrl+enter'
34 }
35
36 export default function ChannelView() {
37 const {
38 currentChannel,
39 messages,
40 isLoadingMessages,
41 sendMessage,
42 loadMoreMessages,
43 isOwnerOrMod,
44 isMember,
45 channelAccessMode,
46 channelParticipants
47 } = useChat()
48 const { pubkey } = useNostr()
49 const [input, setInput] = useState('')
50 const [isSending, setIsSending] = useState(false)
51 const [isLoadingMore, setIsLoadingMore] = useState(false)
52 const [showSettings, setShowSettings] = useState(false)
53 const [showMemberList, setShowMemberList] = useState(false)
54 const [profilePubkey, setProfilePubkey] = useState<string | null>(null)
55 const scrollRef = useRef<HTMLDivElement>(null)
56 const textareaRef = useRef<HTMLTextAreaElement>(null)
57 const shouldAutoScroll = useRef(true)
58 const composerRef = useRef<HTMLDivElement>(null)
59
60 // @ mention state
61 const [mentionQuery, setMentionQuery] = useState<string | null>(null)
62 const [mentionStart, setMentionStart] = useState(0)
63
64 // Auto-scroll to bottom on new messages
65 useEffect(() => {
66 if (shouldAutoScroll.current && scrollRef.current) {
67 scrollRef.current.scrollTop = scrollRef.current.scrollHeight
68 }
69 }, [messages])
70
71 // Scroll to bottom and restore draft when channel changes
72 useEffect(() => {
73 shouldAutoScroll.current = true
74 if (scrollRef.current) {
75 scrollRef.current.scrollTop = scrollRef.current.scrollHeight
76 }
77 if (currentChannel) {
78 const draft = localStorage.getItem(`nirc:draft:${currentChannel.id}`) || ''
79 setInput(draft)
80 } else {
81 setInput('')
82 }
83 setShowSettings(false)
84 setShowMemberList(false)
85 setMentionQuery(null)
86 }, [currentChannel?.id])
87
88 // Focus input on channel select (desktop only)
89 useEffect(() => {
90 if (currentChannel && !isTouchDevice()) {
91 setTimeout(() => textareaRef.current?.focus(), 100)
92 }
93 }, [currentChannel?.id])
94
95 const handleScroll = useCallback(() => {
96 if (!scrollRef.current) return
97 const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
98 shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100
99 }, [])
100
101 const handleLoadMore = async () => {
102 setIsLoadingMore(true)
103 try {
104 await loadMoreMessages()
105 } finally {
106 setIsLoadingMore(false)
107 }
108 }
109
110 const handleSend = async () => {
111 if (!input.trim() || isSending || !pubkey || !currentChannel) return
112 setIsSending(true)
113 try {
114 await sendMessage(input.trim())
115 setInput('')
116 localStorage.removeItem(`nirc:draft:${currentChannel.id}`)
117 shouldAutoScroll.current = true
118 if (textareaRef.current) {
119 textareaRef.current.style.height = 'auto'
120 }
121 } finally {
122 setIsSending(false)
123 setTimeout(() => textareaRef.current?.focus(), 0)
124 }
125 }
126
127 const handleKeyDown = (e: React.KeyboardEvent) => {
128 // Close mention popup on Escape
129 if (e.key === 'Escape' && mentionQuery !== null) {
130 setMentionQuery(null)
131 return
132 }
133 if (e.key === 'Enter') {
134 const sk = loadSubmitKey()
135 if (sk === 'enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) {
136 e.preventDefault()
137 handleSend()
138 } else if (sk === 'ctrl+enter' && (e.ctrlKey || e.metaKey)) {
139 e.preventDefault()
140 handleSend()
141 }
142 }
143 }
144
145 const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
146 const val = e.target.value
147 setInput(val)
148 if (currentChannel) {
149 localStorage.setItem(`nirc:draft:${currentChannel.id}`, val)
150 }
151 resizeTextarea()
152
153 // Detect @ mention
154 const cursorPos = e.target.selectionStart || 0
155 const textBefore = val.slice(0, cursorPos)
156 const atMatch = textBefore.match(/@(\w*)$/)
157 if (atMatch) {
158 setMentionQuery(atMatch[1].toLowerCase())
159 setMentionStart(cursorPos - atMatch[0].length)
160 } else {
161 setMentionQuery(null)
162 }
163 }
164
165 const handleMentionSelect = (selectedPubkey: string, _displayName: string) => {
166 const npub = Pubkey.tryFromString(selectedPubkey)?.npub || selectedPubkey
167 const before = input.slice(0, mentionStart)
168 const after = input.slice(textareaRef.current?.selectionStart || input.length)
169 const newInput = `${before}nostr:${npub} ${after}`
170 setInput(newInput)
171 setMentionQuery(null)
172 if (currentChannel) {
173 localStorage.setItem(`nirc:draft:${currentChannel.id}`, newInput)
174 }
175 setTimeout(() => textareaRef.current?.focus(), 0)
176 }
177
178 const resizeTextarea = useCallback(() => {
179 const ta = textareaRef.current
180 if (!ta) return
181 ta.style.height = 'auto'
182 const max = window.innerHeight * 0.3
183 ta.style.height = `${Math.min(ta.scrollHeight, max)}px`
184 ta.style.overflowY = ta.scrollHeight > max ? 'auto' : 'hidden'
185 }, [])
186
187 if (!currentChannel) {
188 return (
189 <div className="flex flex-col items-center justify-center h-full text-muted-foreground gap-2">
190 <Hash className="size-10" />
191 <span className="text-sm">Select a channel</span>
192 </div>
193 )
194 }
195
196 const isRestricted = channelAccessMode !== 'open' && !isMember
197
198 return (
199 <div className="flex flex-col h-full relative">
200 {/* Channel header */}
201 <div className="flex items-center gap-2 px-3 py-2 border-b">
202 <button
203 className="flex items-center gap-0.5 hover:text-primary"
204 onClick={() => setShowMemberList(!showMemberList)}
205 title="Member list"
206 >
207 <Hash className="size-4 text-muted-foreground" />
208 </button>
209 <span className="font-semibold text-sm">{currentChannel.name}</span>
210 {channelAccessMode !== 'open' && (
211 <span title={channelAccessMode === 'whitelist' ? 'Whitelist' : 'Blacklist'}>
212 <Lock className="size-3 text-muted-foreground" />
213 </span>
214 )}
215 {currentChannel.about && (
216 <span className="text-xs text-muted-foreground truncate">
217 — {currentChannel.about}
218 </span>
219 )}
220 <div className="flex-1" />
221 <Button
222 variant="ghost"
223 size="icon"
224 className="size-7"
225 onClick={() => setShowMemberList(!showMemberList)}
226 title="Member list"
227 >
228 <Users className="size-3.5" />
229 </Button>
230 <Button
231 variant="ghost"
232 size="icon"
233 className="size-7"
234 onClick={() => setShowSettings(!showSettings)}
235 title="Channel settings"
236 >
237 {isOwnerOrMod ? <Shield className="size-3.5" /> : <Settings2 className="size-3.5" />}
238 </Button>
239 </div>
240
241 {/* Settings overlay */}
242 {showSettings && (
243 <ChannelSettingsPanel onClose={() => setShowSettings(false)} />
244 )}
245
246 {/* Member list panel */}
247 {showMemberList && !showSettings && (
248 <MemberListPanel onClose={() => setShowMemberList(false)} />
249 )}
250
251 {/* Messages */}
252 <div
253 ref={scrollRef}
254 onScroll={handleScroll}
255 className="flex-1 overflow-y-auto px-3 py-2 space-y-1"
256 >
257 {isRestricted ? (
258 <div className="flex flex-col items-center justify-center h-full gap-3 text-muted-foreground">
259 <Lock className="size-8" />
260 <span className="text-sm">This channel requires access</span>
261 <RequestAccessButton />
262 </div>
263 ) : (
264 <>
265 {messages.length > 0 && (
266 <div className="flex justify-center py-2">
267 <Button
268 variant="ghost"
269 size="sm"
270 onClick={handleLoadMore}
271 disabled={isLoadingMore}
272 className="text-xs"
273 >
274 {isLoadingMore ? (
275 <Loader2 className="size-3 animate-spin mr-1" />
276 ) : (
277 <ChevronUp className="size-3 mr-1" />
278 )}
279 Load older
280 </Button>
281 </div>
282 )}
283
284 {isLoadingMessages ? (
285 <div className="flex justify-center py-8">
286 <Loader2 className="size-5 animate-spin text-muted-foreground" />
287 </div>
288 ) : messages.length === 0 ? (
289 <div className="text-center py-8 text-sm text-muted-foreground">
290 No messages yet. Say something.
291 </div>
292 ) : (
293 messages.map((msg) => (
294 <ChatMessage
295 key={msg.id}
296 msg={msg}
297 isOwn={msg.pubkey === pubkey}
298 showModActions={isOwnerOrMod && msg.pubkey !== pubkey}
299 onUsernameClick={setProfilePubkey}
300 />
301 ))
302 )}
303 </>
304 )}
305 </div>
306
307 {/* Composer */}
308 {pubkey && isMember && !isRestricted && (
309 <div ref={composerRef} className="border-t p-2 flex items-end gap-2 relative">
310 {/* Mention popup */}
311 {mentionQuery !== null && (
312 <MentionPopup
313 participants={channelParticipants}
314 query={mentionQuery}
315 onSelect={handleMentionSelect}
316 position={{ bottom: composerRef.current?.clientHeight || 48, left: 8 }}
317 />
318 )}
319 <Textarea
320 ref={textareaRef}
321 value={input}
322 onChange={handleInputChange}
323 onKeyDown={handleKeyDown}
324 placeholder={`Message #${currentChannel.name}`}
325 className="min-h-[36px] resize-none overflow-hidden text-sm"
326 disabled={isSending}
327 />
328 <Button
329 onClick={handleSend}
330 disabled={!input.trim() || isSending}
331 size="icon"
332 className="flex-shrink-0 size-9"
333 >
334 {isSending ? (
335 <Loader2 className="size-4 animate-spin" />
336 ) : (
337 <Send className="size-4" />
338 )}
339 </Button>
340 </div>
341 )}
342
343 {/* User profile modal */}
344 {profilePubkey && (
345 <UserProfileModal
346 pubkeyHex={profilePubkey}
347 onClose={() => setProfilePubkey(null)}
348 />
349 )}
350 </div>
351 )
352 }
353
354 function RequestAccessButton() {
355 const { currentChannel } = useChat()
356 const { pubkey, signEvent } = useNostr()
357 const [sent, setSent] = useState(false)
358 const [sending, setSending] = useState(false)
359
360 const handleRequest = async () => {
361 if (!currentChannel || !pubkey || sent) return
362 setSending(true)
363 try {
364 // Send a DM to channel creator requesting access
365 const { default: clientService } = await import('@/services/client.service')
366 const content = `nirc:request:${currentChannel.id}:${currentChannel.name}`
367 const draft = {
368 kind: 4,
369 created_at: Math.floor(Date.now() / 1000),
370 tags: [['p', currentChannel.creator]],
371 content
372 }
373 const signed = await signEvent(draft)
374 await clientService.publishEvent(['wss://relay.orly.dev/'], signed)
375 setSent(true)
376 } catch {
377 // ignore
378 } finally {
379 setSending(false)
380 }
381 }
382
383 if (!pubkey) return null
384
385 return (
386 <Button
387 variant="outline"
388 size="sm"
389 className="gap-1.5"
390 onClick={handleRequest}
391 disabled={sent || sending}
392 >
393 {sending ? (
394 <Loader2 className="size-3 animate-spin" />
395 ) : (
396 <LogIn className="size-3" />
397 )}
398 {sent ? 'Request Sent' : 'Request Access'}
399 </Button>
400 )
401 }
402
403 function ChatMessage({
404 msg,
405 isOwn,
406 showModActions,
407 onUsernameClick
408 }: {
409 msg: { id: string; pubkey: string; content: string; createdAt: number; event?: import('nostr-tools').Event }
410 isOwn: boolean
411 showModActions: boolean
412 onUsernameClick: (pubkey: string) => void
413 }) {
414 const { hideMessage, blockUser } = useChat()
415 const { profile } = useFetchProfile(msg.pubkey)
416 const pk = Pubkey.tryFromString(msg.pubkey)
417 const displayName = profile?.username || pk?.formatNpub(8) || msg.pubkey.slice(0, 12)
418 const time = dayjs.unix(msg.createdAt).format('HH:mm')
419 const isAction = msg.content.startsWith('/me ')
420 const actionText = isAction ? msg.content.slice(4) : null
421
422 return (
423 <div className="group flex items-start gap-0 py-0.5 text-sm font-mono leading-snug">
424 <span className="text-[11px] text-muted-foreground shrink-0">[{time}] </span>
425 {isAction ? (
426 <span className="italic break-words min-w-0">
427 <span className="text-muted-foreground">*</span>{' '}
428 <button
429 className={`${isOwn ? 'text-muted-foreground' : 'text-primary'} hover:underline cursor-pointer`}
430 onClick={() => onUsernameClick(msg.pubkey)}
431 >
432 {displayName}
433 </button>{' '}
434 <ChatContent content={actionText!} event={msg.event} />
435 </span>
436 ) : (
437 <>
438 <span className="shrink-0">
439 <span className="text-muted-foreground"><</span>
440 <button
441 className={`${isOwn ? 'text-muted-foreground' : 'text-primary font-medium'} hover:underline cursor-pointer`}
442 onClick={() => onUsernameClick(msg.pubkey)}
443 >
444 {displayName}
445 </button>
446 <span className="text-muted-foreground">></span>
447 </span>
448 <span className="min-w-0"> <ChatContent content={msg.content} event={msg.event} /></span>
449 </>
450 )}
451 {showModActions && (
452 <span className="opacity-0 group-hover:opacity-100 transition-opacity flex gap-0.5 ml-1 shrink-0">
453 <button
454 onClick={() => hideMessage(msg.id)}
455 className="text-muted-foreground hover:text-destructive p-0.5"
456 title="Hide message"
457 >
458 <EyeOff className="size-3" />
459 </button>
460 <button
461 onClick={() => blockUser(msg.pubkey)}
462 className="text-muted-foreground hover:text-destructive p-0.5"
463 title="Block user"
464 >
465 <Ban className="size-3" />
466 </button>
467 </span>
468 )}
469 </div>
470 )
471 }
472