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}]&nbsp;</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">&lt;</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">&gt;</span>
 447            </span>
 448            <span className="min-w-0">&nbsp;<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