ConversationList.tsx raw

   1  import { toDMConversation } from '@/lib/link'
   2  import { useSecondaryPage } from '@/PageManager'
   3  import { useDM } from '@/providers/DMProvider'
   4  import { useFollowList } from '@/providers/FollowListProvider'
   5  import { useMuteList } from '@/providers/MuteListProvider'
   6  import storage from '@/services/local-storage.service'
   7  import { Check, Loader2, MessageSquare, MoreVertical, RefreshCw } from 'lucide-react'
   8  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
   9  import { useTranslation } from 'react-i18next'
  10  import { Button } from '../ui/button'
  11  import {
  12    DropdownMenu,
  13    DropdownMenuContent,
  14    DropdownMenuItem,
  15    DropdownMenuTrigger
  16  } from '../ui/dropdown-menu'
  17  import { ScrollArea } from '../ui/scroll-area'
  18  import ConversationItem from './ConversationItem'
  19  
  20  export default function ConversationList() {
  21    const { t } = useTranslation()
  22    const { push, pop } = useSecondaryPage()
  23    const {
  24      conversations,
  25      currentConversation,
  26      selectConversation,
  27      refreshConversations,
  28      loadMoreConversations,
  29      hasMoreConversations,
  30      isLoading
  31    } = useDM()
  32    const { followingSet } = useFollowList()
  33    const { mutePubkeySet } = useMuteList()
  34    const loadMoreRef = useRef<HTMLDivElement>(null)
  35    const [filterMode, setFilterMode] = useState<'all' | 'follows'>(() =>
  36      storage.getDMConversationFilter()
  37    )
  38  
  39    // Filter and sort conversations
  40    const sortedConversations = useMemo(() => {
  41      let filtered = [...conversations]
  42  
  43      if (filterMode === 'follows') {
  44        // Only show conversations from follows, and hide muted users
  45        filtered = filtered.filter(
  46          (c) => followingSet.has(c.partnerPubkey) && !mutePubkeySet.has(c.partnerPubkey)
  47        )
  48      }
  49  
  50      return filtered.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
  51    }, [conversations, filterMode, followingSet, mutePubkeySet])
  52  
  53    const handleFilterChange = (mode: 'all' | 'follows') => {
  54      setFilterMode(mode)
  55      storage.setDMConversationFilter(mode)
  56    }
  57  
  58    // Infinite scroll: load more when sentinel is visible
  59    const handleIntersection = useCallback(
  60      (entries: IntersectionObserverEntry[]) => {
  61        const [entry] = entries
  62        if (entry.isIntersecting && hasMoreConversations && !isLoading) {
  63          loadMoreConversations()
  64        }
  65      },
  66      [hasMoreConversations, isLoading, loadMoreConversations]
  67    )
  68  
  69    useEffect(() => {
  70      const observer = new IntersectionObserver(handleIntersection, {
  71        root: null,
  72        rootMargin: '100px',
  73        threshold: 0
  74      })
  75  
  76      if (loadMoreRef.current) {
  77        observer.observe(loadMoreRef.current)
  78      }
  79  
  80      return () => observer.disconnect()
  81    }, [handleIntersection])
  82  
  83    return (
  84      <div className="flex flex-col h-full">
  85        <div className="flex items-center justify-between p-3 border-b">
  86          <span className="font-medium text-sm">{t('Conversations')}</span>
  87          <div className="flex items-center gap-1">
  88            <Button
  89              variant="ghost"
  90              size="icon"
  91              className="size-8"
  92              onClick={refreshConversations}
  93              disabled={isLoading}
  94            >
  95              <RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} />
  96            </Button>
  97            <DropdownMenu>
  98              <DropdownMenuTrigger asChild>
  99                <Button variant="ghost" size="icon" className="size-8">
 100                  <MoreVertical className="size-4" />
 101                </Button>
 102              </DropdownMenuTrigger>
 103              <DropdownMenuContent align="end">
 104                <DropdownMenuItem onClick={() => handleFilterChange('follows')}>
 105                  {filterMode === 'follows' && <Check className="size-4 mr-2" />}
 106                  <span className={filterMode !== 'follows' ? 'ml-6' : ''}>
 107                    {t('Only show follows')}
 108                  </span>
 109                </DropdownMenuItem>
 110                <DropdownMenuItem onClick={() => handleFilterChange('all')}>
 111                  {filterMode === 'all' && <Check className="size-4 mr-2" />}
 112                  <span className={filterMode !== 'all' ? 'ml-6' : ''}>{t('Show all')}</span>
 113                </DropdownMenuItem>
 114              </DropdownMenuContent>
 115            </DropdownMenu>
 116          </div>
 117        </div>
 118  
 119        <ScrollArea className="flex-1">
 120          {sortedConversations.length === 0 && !isLoading ? (
 121            <div className="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground px-4">
 122              <MessageSquare className="size-8" />
 123              <p className="text-sm text-center">{t('No conversations yet')}</p>
 124              <p className="text-xs text-center">{t('Start a conversation by visiting a profile')}</p>
 125            </div>
 126          ) : (
 127            <div className="divide-y">
 128              {sortedConversations.map((conversation, index) => (
 129                <ConversationItem
 130                  key={conversation.partnerPubkey}
 131                  conversation={conversation}
 132                  isActive={currentConversation === conversation.partnerPubkey}
 133                  isFollowing={followingSet.has(conversation.partnerPubkey)}
 134                  navIndex={index}
 135                  onClick={() => {
 136                    // If already viewing a different conversation, pop first to replace
 137                    if (currentConversation && currentConversation !== conversation.partnerPubkey) {
 138                      pop()
 139                    }
 140                    push(toDMConversation(conversation.partnerPubkey))
 141                  }}
 142                  onClose={() => {
 143                    selectConversation(null)
 144                    pop()
 145                  }}
 146                />
 147              ))}
 148              {/* Sentinel element for infinite scroll */}
 149              {hasMoreConversations && (
 150                <div ref={loadMoreRef} className="flex justify-center py-4">
 151                  <Loader2 className="size-5 animate-spin text-muted-foreground" />
 152                </div>
 153              )}
 154            </div>
 155          )}
 156        </ScrollArea>
 157      </div>
 158    )
 159  }
 160