MessageView.tsx raw

   1  import UserAvatar from '@/components/UserAvatar'
   2  import { formatTimestamp } from '@/lib/timestamp'
   3  import { cn } from '@/lib/utils'
   4  import { useDM } from '@/providers/DMProvider'
   5  import { useNostr } from '@/providers/NostrProvider'
   6  import client from '@/services/client.service'
   7  import indexedDb from '@/services/indexed-db.service'
   8  import { TDirectMessage, TProfile } from '@/types'
   9  import { ChevronDown, ChevronUp, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
  10  import { useEffect, useRef, useState } from 'react'
  11  import { useTranslation } from 'react-i18next'
  12  import { Button } from '../ui/button'
  13  import { ScrollArea } from '../ui/scroll-area'
  14  import { Checkbox } from '../ui/checkbox'
  15  import {
  16    DropdownMenu,
  17    DropdownMenuContent,
  18    DropdownMenuItem,
  19    DropdownMenuTrigger
  20  } from '../ui/dropdown-menu'
  21  import MessageComposer from './MessageComposer'
  22  import MessageContent from './MessageContent'
  23  import MessageInfoModal from './MessageInfoModal'
  24  import ConversationSettingsModal from './ConversationSettingsModal'
  25  import { useFollowList } from '@/providers/FollowListProvider'
  26  
  27  interface MessageViewProps {
  28    onBack?: () => void
  29    hideHeader?: boolean
  30  }
  31  
  32  export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
  33    const { t } = useTranslation()
  34    const { pubkey } = useNostr()
  35    const {
  36      currentConversation,
  37      messages,
  38      isLoadingConversation,
  39      isNewConversation,
  40      clearNewConversationFlag,
  41      reloadConversation,
  42      // Selection mode
  43      selectedMessages,
  44      isSelectionMode,
  45      toggleMessageSelection,
  46      clearSelection,
  47      deleteSelectedMessages,
  48      deleteAllInConversation,
  49      undeleteAllInConversation
  50    } = useDM()
  51    const { followingSet } = useFollowList()
  52    const [profile, setProfile] = useState<TProfile | null>(null)
  53    const scrollRef = useRef<HTMLDivElement>(null)
  54    const [selectedMessage, setSelectedMessage] = useState<TDirectMessage | null>(null)
  55    const [messageInfoOpen, setMessageInfoOpen] = useState(false)
  56    const [settingsOpen, setSettingsOpen] = useState(false)
  57    const [selectedRelays, setSelectedRelays] = useState<string[]>([])
  58    const [showPulse, setShowPulse] = useState(false)
  59    const [showJumpButton, setShowJumpButton] = useState(false)
  60    const [newMessageCount, setNewMessageCount] = useState(0)
  61    const lastMessageCountRef = useRef(0)
  62    const isAtBottomRef = useRef(true)
  63    // Progressive loading: start with 20 messages, load more on demand
  64    const [visibleLimit, setVisibleLimit] = useState(20)
  65    const LOAD_MORE_INCREMENT = 20
  66  
  67    const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
  68  
  69    // Calculate visible messages (show most recent, allow loading older)
  70    const hasMoreMessages = messages.length > visibleLimit
  71    const visibleMessages = hasMoreMessages
  72      ? messages.slice(-visibleLimit) // Show last N messages (most recent)
  73      : messages
  74  
  75    // Load more older messages
  76    const loadMoreMessages = () => {
  77      setVisibleLimit((prev) => prev + LOAD_MORE_INCREMENT)
  78    }
  79  
  80    // Reset visible limit when conversation changes
  81    useEffect(() => {
  82      setVisibleLimit(20)
  83    }, [currentConversation])
  84  
  85    // Handle pulsing animation for new conversations
  86    useEffect(() => {
  87      if (isNewConversation) {
  88        setShowPulse(true)
  89        const timer = setTimeout(() => {
  90          setShowPulse(false)
  91          clearNewConversationFlag()
  92        }, 10000)
  93        return () => clearTimeout(timer)
  94      }
  95    }, [isNewConversation, clearNewConversationFlag])
  96  
  97    useEffect(() => {
  98      if (!currentConversation) return
  99  
 100      const fetchProfileData = async () => {
 101        try {
 102          const profileData = await client.fetchProfile(currentConversation)
 103          if (profileData) {
 104            setProfile(profileData)
 105          }
 106        } catch (error) {
 107          console.error('Failed to fetch profile:', error)
 108        }
 109      }
 110      fetchProfileData()
 111    }, [currentConversation])
 112  
 113    // Load saved relay settings when conversation changes
 114    useEffect(() => {
 115      if (!currentConversation || !pubkey) return
 116  
 117      const loadRelaySettings = async () => {
 118        const saved = await indexedDb.getConversationRelaySettings(pubkey, currentConversation)
 119        setSelectedRelays(saved || [])
 120      }
 121      loadRelaySettings()
 122    }, [currentConversation, pubkey])
 123  
 124    // Save relay settings when they change
 125    const handleRelaysChange = async (relays: string[]) => {
 126      setSelectedRelays(relays)
 127      if (pubkey && currentConversation) {
 128        await indexedDb.putConversationRelaySettings(pubkey, currentConversation, relays)
 129      }
 130    }
 131  
 132    // Handle scroll position tracking
 133    const handleScroll = () => {
 134      if (!scrollRef.current) return
 135      const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
 136      const distanceFromBottom = scrollHeight - scrollTop - clientHeight
 137      const atBottom = distanceFromBottom < 100 // 100px threshold
 138  
 139      isAtBottomRef.current = atBottom
 140      setShowJumpButton(!atBottom)
 141  
 142      // Reset new message count when user scrolls to bottom
 143      if (atBottom) {
 144        setNewMessageCount(0)
 145        lastMessageCountRef.current = messages.length
 146      }
 147    }
 148  
 149    // Track new messages when scrolled up
 150    useEffect(() => {
 151      if (!isAtBottomRef.current && messages.length > lastMessageCountRef.current) {
 152        setNewMessageCount(messages.length - lastMessageCountRef.current)
 153      } else if (isAtBottomRef.current) {
 154        lastMessageCountRef.current = messages.length
 155      }
 156    }, [messages.length])
 157  
 158    // Scroll to bottom when messages change (only if already at bottom)
 159    useEffect(() => {
 160      if (scrollRef.current && isAtBottomRef.current) {
 161        scrollRef.current.scrollTop = scrollRef.current.scrollHeight
 162        lastMessageCountRef.current = messages.length
 163      }
 164    }, [messages])
 165  
 166    // Scroll to bottom function
 167    const scrollToBottom = () => {
 168      if (scrollRef.current) {
 169        scrollRef.current.scrollTo({
 170          top: scrollRef.current.scrollHeight,
 171          behavior: 'smooth'
 172        })
 173        setNewMessageCount(0)
 174        lastMessageCountRef.current = messages.length
 175        isAtBottomRef.current = true
 176        setShowJumpButton(false)
 177      }
 178    }
 179  
 180    // Reset scroll state when conversation changes
 181    useEffect(() => {
 182      isAtBottomRef.current = true
 183      setShowJumpButton(false)
 184      setNewMessageCount(0)
 185      lastMessageCountRef.current = 0
 186    }, [currentConversation])
 187  
 188    // Scroll to bottom when conversation opens and messages are loaded
 189    const hasMessages = messages.length > 0
 190    useEffect(() => {
 191      if (currentConversation && hasMessages && scrollRef.current) {
 192        // Use requestAnimationFrame to ensure DOM is ready
 193        requestAnimationFrame(() => {
 194          if (scrollRef.current) {
 195            scrollRef.current.scrollTop = scrollRef.current.scrollHeight
 196            lastMessageCountRef.current = messages.length
 197          }
 198        })
 199      }
 200    }, [currentConversation, hasMessages])
 201  
 202    if (!currentConversation || !pubkey) {
 203      return null
 204    }
 205  
 206    const displayName = profile?.username || currentConversation.slice(0, 8) + '...'
 207  
 208    return (
 209      <div className="flex flex-col h-full">
 210        {/* Header - show when not hidden, or when in selection mode */}
 211        {(!hideHeader || isSelectionMode) && (
 212          <div className="flex items-center gap-3 p-3 border-b">
 213            {isSelectionMode ? (
 214              // Selection mode header
 215              <>
 216                <Button
 217                  variant="ghost"
 218                  size="icon"
 219                  onClick={clearSelection}
 220                  className="size-8"
 221                  title={t('Cancel')}
 222                >
 223                  <X className="size-4" />
 224                </Button>
 225                <div className="flex items-center gap-2">
 226                  <Trash2 className="size-4 text-destructive" />
 227                  <span className="font-medium text-sm">{t('Delete')}</span>
 228                </div>
 229                <div className="flex-1" />
 230                <Button
 231                  variant="outline"
 232                  size="sm"
 233                  onClick={deleteSelectedMessages}
 234                  disabled={selectedMessages.size === 0}
 235                  className="text-xs"
 236                >
 237                  {t('Selected')} ({selectedMessages.size})
 238                </Button>
 239                <Button
 240                  variant="destructive"
 241                  size="sm"
 242                  onClick={deleteAllInConversation}
 243                  className="text-xs"
 244                >
 245                  {t('All')}
 246                </Button>
 247              </>
 248            ) : (
 249              // Normal header
 250              <>
 251                <UserAvatar userId={currentConversation} className="size-8" />
 252                <div className="flex-1 min-w-0">
 253                  <div className="flex items-center gap-1.5">
 254                    <span className="font-medium text-sm truncate">{displayName}</span>
 255                    {isFollowing && (
 256                      <span title="Following">
 257                        <Users className="size-3 text-primary" />
 258                      </span>
 259                    )}
 260                  </div>
 261                  {profile?.nip05 && (
 262                    <span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
 263                  )}
 264                </div>
 265                <Button
 266                  variant="ghost"
 267                  size="icon"
 268                  className="size-8"
 269                  title={t('Reload messages')}
 270                  onClick={reloadConversation}
 271                  disabled={isLoadingConversation}
 272                >
 273                  <RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
 274                </Button>
 275                <Button
 276                  variant="ghost"
 277                  size="icon"
 278                  className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
 279                  title={t('Conversation settings')}
 280                  onClick={() => {
 281                    setShowPulse(false)
 282                    clearNewConversationFlag()
 283                    setSettingsOpen(true)
 284                  }}
 285                >
 286                  <Settings className="size-4" />
 287                </Button>
 288                <DropdownMenu>
 289                  <DropdownMenuTrigger asChild>
 290                    <Button variant="ghost" size="icon" className="size-8">
 291                      <MoreVertical className="size-4" />
 292                    </Button>
 293                  </DropdownMenuTrigger>
 294                  <DropdownMenuContent align="end">
 295                    <DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
 296                      <Trash2 className="size-4 mr-2" />
 297                      {t('Delete All')}
 298                    </DropdownMenuItem>
 299                    <DropdownMenuItem onClick={undeleteAllInConversation}>
 300                      <Undo2 className="size-4 mr-2" />
 301                      {t('Undelete All')}
 302                    </DropdownMenuItem>
 303                  </DropdownMenuContent>
 304                </DropdownMenu>
 305                {onBack && (
 306                  <Button
 307                    variant="ghost"
 308                    size="icon"
 309                    className="size-8"
 310                    title={t('Close conversation')}
 311                    onClick={onBack}
 312                  >
 313                    <X className="size-4" />
 314                  </Button>
 315                )}
 316              </>
 317            )}
 318          </div>
 319        )}
 320  
 321        {/* Messages */}
 322        <div className="flex-1 relative overflow-hidden">
 323          <ScrollArea ref={scrollRef} className="h-full p-3" onScrollCapture={handleScroll}>
 324          {isLoadingConversation && messages.length === 0 ? (
 325            <div className="flex items-center justify-center h-full">
 326              <Loader2 className="size-6 animate-spin text-muted-foreground" />
 327            </div>
 328          ) : messages.length === 0 ? (
 329            <div className="flex items-center justify-center h-full text-muted-foreground">
 330              <p className="text-sm">{t('No messages yet. Send one to start the conversation!')}</p>
 331            </div>
 332          ) : (
 333            <div className="space-y-3">
 334              {/* Load more button at top */}
 335              {hasMoreMessages && (
 336                <div className="flex justify-center py-2">
 337                  <Button
 338                    variant="ghost"
 339                    size="sm"
 340                    onClick={loadMoreMessages}
 341                    className="text-xs text-muted-foreground"
 342                  >
 343                    <ChevronUp className="size-4 mr-1" />
 344                    {t('Load older messages')} ({messages.length - visibleLimit} more)
 345                  </Button>
 346                </div>
 347              )}
 348              {isLoadingConversation && (
 349                <div className="flex justify-center py-2">
 350                  <Loader2 className="size-4 animate-spin text-muted-foreground" />
 351                </div>
 352              )}
 353              {visibleMessages.map((message) => {
 354                const isOwn = message.senderPubkey === pubkey
 355                const isSelected = selectedMessages.has(message.id)
 356                return (
 357                  <div
 358                    key={message.id}
 359                    className={cn(
 360                      'flex items-start gap-2 group',
 361                      isOwn ? 'flex-row-reverse' : 'flex-row'
 362                    )}
 363                  >
 364                    {/* Checkbox - shows on hover or when in selection mode */}
 365                    <div
 366                      className={cn(
 367                        'flex-shrink-0 transition-opacity',
 368                        isSelectionMode || isSelected
 369                          ? 'opacity-100'
 370                          : 'opacity-0 group-hover:opacity-100'
 371                      )}
 372                    >
 373                      <Checkbox
 374                        checked={isSelected}
 375                        onCheckedChange={() => toggleMessageSelection(message.id)}
 376                        className="mt-2"
 377                      />
 378                    </div>
 379                    <div
 380                      className={cn(
 381                        'max-w-[80%] rounded-lg px-3 py-2',
 382                        isOwn
 383                          ? 'bg-primary text-primary-foreground'
 384                          : 'bg-muted',
 385                        isSelected && 'ring-2 ring-primary ring-offset-2'
 386                      )}
 387                    >
 388                      <MessageContent
 389                        content={message.content}
 390                        className="text-sm"
 391                        isOwnMessage={isOwn}
 392                      />
 393                      <div
 394                        className={cn(
 395                          'flex items-center justify-between gap-2 mt-1 text-xs',
 396                          isOwn ? 'text-primary-foreground/70' : 'text-muted-foreground'
 397                        )}
 398                      >
 399                        <span>{formatTimestamp(message.createdAt)}</span>
 400                        <button
 401                          onClick={() => {
 402                            setSelectedMessage(message)
 403                            setMessageInfoOpen(true)
 404                          }}
 405                          className={cn(
 406                            'font-mono opacity-60 hover:opacity-100 transition-opacity',
 407                            isOwn ? 'hover:text-primary-foreground' : 'hover:text-foreground'
 408                          )}
 409                          title={t('Message info')}
 410                        >
 411                          {message.encryptionType === 'nip17' ? '44' : '4'}
 412                        </button>
 413                      </div>
 414                    </div>
 415                  </div>
 416                )
 417              })}
 418            </div>
 419          )}
 420          </ScrollArea>
 421  
 422          {/* Jump to newest button */}
 423          {showJumpButton && (
 424            <Button
 425              onClick={scrollToBottom}
 426              className="absolute bottom-4 right-4 rounded-full shadow-lg size-10 p-0"
 427              size="icon"
 428            >
 429              <ChevronDown className="size-5" />
 430              {newMessageCount > 0 && (
 431                <span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full min-w-5 h-5 flex items-center justify-center text-xs font-medium px-1">
 432                  {newMessageCount > 99 ? '99+' : newMessageCount}
 433                </span>
 434              )}
 435            </Button>
 436          )}
 437        </div>
 438  
 439        {/* Composer */}
 440        <div className="border-t">
 441          <MessageComposer />
 442        </div>
 443  
 444        {/* Message Info Modal */}
 445        <MessageInfoModal
 446          message={selectedMessage}
 447          open={messageInfoOpen}
 448          onOpenChange={setMessageInfoOpen}
 449        />
 450  
 451        {/* Conversation Settings Modal */}
 452        <ConversationSettingsModal
 453          partnerPubkey={currentConversation}
 454          open={settingsOpen}
 455          onOpenChange={setSettingsOpen}
 456          selectedRelays={selectedRelays}
 457          onSelectedRelaysChange={handleRelaysChange}
 458        />
 459      </div>
 460    )
 461  }
 462