import { useChat } from '@/providers/ChatProvider' import { useNostr } from '@/providers/NostrProvider' import { useFetchProfile } from '@/hooks/useFetchProfile' import { isTouchDevice } from '@/lib/utils' import { Pubkey } from '@/domain' import { Hash, Loader2, Send, ChevronUp, Shield, EyeOff, Ban, Lock, Settings2, Users, LogIn } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { Button } from '../ui/button' import { Textarea } from '../ui/textarea' import dayjs from 'dayjs' import ChannelSettingsPanel from './ChannelSettingsPanel' import ChatContent from './ChatContent' import UserProfileModal from './UserProfileModal' import MentionPopup from './MentionPopup' import MemberListPanel from './MemberListPanel' type TSubmitKey = 'enter' | 'ctrl+enter' function loadSubmitKey(): TSubmitKey { const v = localStorage.getItem('nirc:submitKey') return v === 'enter' ? 'enter' : 'ctrl+enter' } export default function ChannelView() { const { currentChannel, messages, isLoadingMessages, sendMessage, loadMoreMessages, isOwnerOrMod, isMember, channelAccessMode, channelParticipants } = useChat() const { pubkey } = useNostr() const [input, setInput] = useState('') const [isSending, setIsSending] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false) const [showSettings, setShowSettings] = useState(false) const [showMemberList, setShowMemberList] = useState(false) const [profilePubkey, setProfilePubkey] = useState(null) const scrollRef = useRef(null) const textareaRef = useRef(null) const shouldAutoScroll = useRef(true) const composerRef = useRef(null) // @ mention state const [mentionQuery, setMentionQuery] = useState(null) const [mentionStart, setMentionStart] = useState(0) // Auto-scroll to bottom on new messages useEffect(() => { if (shouldAutoScroll.current && scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } }, [messages]) // Scroll to bottom and restore draft when channel changes useEffect(() => { shouldAutoScroll.current = true if (scrollRef.current) { scrollRef.current.scrollTop = scrollRef.current.scrollHeight } if (currentChannel) { const draft = localStorage.getItem(`nirc:draft:${currentChannel.id}`) || '' setInput(draft) } else { setInput('') } setShowSettings(false) setShowMemberList(false) setMentionQuery(null) }, [currentChannel?.id]) // Focus input on channel select (desktop only) useEffect(() => { if (currentChannel && !isTouchDevice()) { setTimeout(() => textareaRef.current?.focus(), 100) } }, [currentChannel?.id]) const handleScroll = useCallback(() => { if (!scrollRef.current) return const { scrollTop, scrollHeight, clientHeight } = scrollRef.current shouldAutoScroll.current = scrollHeight - scrollTop - clientHeight < 100 }, []) const handleLoadMore = async () => { setIsLoadingMore(true) try { await loadMoreMessages() } finally { setIsLoadingMore(false) } } const handleSend = async () => { if (!input.trim() || isSending || !pubkey || !currentChannel) return setIsSending(true) try { await sendMessage(input.trim()) setInput('') localStorage.removeItem(`nirc:draft:${currentChannel.id}`) shouldAutoScroll.current = true if (textareaRef.current) { textareaRef.current.style.height = 'auto' } } finally { setIsSending(false) setTimeout(() => textareaRef.current?.focus(), 0) } } const handleKeyDown = (e: React.KeyboardEvent) => { // Close mention popup on Escape if (e.key === 'Escape' && mentionQuery !== null) { setMentionQuery(null) return } if (e.key === 'Enter') { const sk = loadSubmitKey() if (sk === 'enter' && !e.shiftKey && !e.ctrlKey && !e.metaKey) { e.preventDefault() handleSend() } else if (sk === 'ctrl+enter' && (e.ctrlKey || e.metaKey)) { e.preventDefault() handleSend() } } } const handleInputChange = (e: React.ChangeEvent) => { const val = e.target.value setInput(val) if (currentChannel) { localStorage.setItem(`nirc:draft:${currentChannel.id}`, val) } resizeTextarea() // Detect @ mention const cursorPos = e.target.selectionStart || 0 const textBefore = val.slice(0, cursorPos) const atMatch = textBefore.match(/@(\w*)$/) if (atMatch) { setMentionQuery(atMatch[1].toLowerCase()) setMentionStart(cursorPos - atMatch[0].length) } else { setMentionQuery(null) } } const handleMentionSelect = (selectedPubkey: string, _displayName: string) => { const npub = Pubkey.tryFromString(selectedPubkey)?.npub || selectedPubkey const before = input.slice(0, mentionStart) const after = input.slice(textareaRef.current?.selectionStart || input.length) const newInput = `${before}nostr:${npub} ${after}` setInput(newInput) setMentionQuery(null) if (currentChannel) { localStorage.setItem(`nirc:draft:${currentChannel.id}`, newInput) } setTimeout(() => textareaRef.current?.focus(), 0) } const resizeTextarea = useCallback(() => { const ta = textareaRef.current if (!ta) return ta.style.height = 'auto' const max = window.innerHeight * 0.3 ta.style.height = `${Math.min(ta.scrollHeight, max)}px` ta.style.overflowY = ta.scrollHeight > max ? 'auto' : 'hidden' }, []) if (!currentChannel) { return (
Select a channel
) } const isRestricted = channelAccessMode !== 'open' && !isMember return (
{/* Channel header */}
{currentChannel.name} {channelAccessMode !== 'open' && ( )} {currentChannel.about && ( — {currentChannel.about} )}
{/* Settings overlay */} {showSettings && ( setShowSettings(false)} /> )} {/* Member list panel */} {showMemberList && !showSettings && ( setShowMemberList(false)} /> )} {/* Messages */}
{isRestricted ? (
This channel requires access
) : ( <> {messages.length > 0 && (
)} {isLoadingMessages ? (
) : messages.length === 0 ? (
No messages yet. Say something.
) : ( messages.map((msg) => ( )) )} )}
{/* Composer */} {pubkey && isMember && !isRestricted && (
{/* Mention popup */} {mentionQuery !== null && ( )}