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