22
33import Link from "next/link" ;
44import { usePathname } from "next/navigation" ;
5- import { Avatar , AvatarFallback } from "@/components/ui/avatar" ;
5+ import { Avatar , AvatarFallback , AvatarImage } from "@/components/ui/avatar" ;
66import { Badge } from "@/components/ui/badge" ;
77import { ScrollArea } from "@/components/ui/scroll-area" ;
88import { Input } from "@/components/ui/input" ;
99import { cn } from "@/lib/utils" ;
10- import { useRef , useState , useEffect , useMemo } from "react" ;
11- import { Search } from "lucide-react" ;
10+ import { useRef , useState , useEffect , useMemo , useCallback } from "react" ;
11+ import { Search , Loader2 } from "lucide-react" ;
1212
1313interface Chat {
1414 id : string ;
@@ -27,6 +27,7 @@ interface Chat {
2727interface ChatListProps {
2828 chats : Chat [ ] ;
2929 slug : string ;
30+ whatsappId : string ;
3031}
3132
3233function formatMessageTime ( timestamp : number | null | undefined ) : string {
@@ -94,7 +95,44 @@ function MarqueeText({ text, className }: { text: string; className?: string })
9495 ) ;
9596}
9697
97- export function ChatList ( { chats, slug } : ChatListProps ) {
98+ function useProfilePictures ( whatsappId : string , visibleIdentifiers : string [ ] ) {
99+ const [ picUrls , setPicUrls ] = useState < Map < string , string | null > > ( new Map ( ) ) ;
100+ const fetchingRef = useRef < Set < string > > ( new Set ( ) ) ;
101+
102+ useEffect ( ( ) => {
103+ const toFetch = visibleIdentifiers . filter (
104+ ( id ) => ! picUrls . has ( id ) && ! fetchingRef . current . has ( id )
105+ ) ;
106+ if ( toFetch . length === 0 ) return ;
107+
108+ for ( const jid of toFetch ) {
109+ fetchingRef . current . add ( jid ) ;
110+ fetch ( `/api/whatsapp/${ whatsappId } /profile-picture?jid=${ encodeURIComponent ( jid ) } ` )
111+ . then ( ( res ) => res . json ( ) )
112+ . then ( ( data : { url : string | null } ) => {
113+ setPicUrls ( ( prev ) => {
114+ const next = new Map ( prev ) ;
115+ next . set ( jid , data . url ) ;
116+ return next ;
117+ } ) ;
118+ } )
119+ . catch ( ( ) => {
120+ setPicUrls ( ( prev ) => {
121+ const next = new Map ( prev ) ;
122+ next . set ( jid , null ) ;
123+ return next ;
124+ } ) ;
125+ } )
126+ . finally ( ( ) => {
127+ fetchingRef . current . delete ( jid ) ;
128+ } ) ;
129+ }
130+ } , [ whatsappId , visibleIdentifiers , picUrls ] ) ;
131+
132+ return picUrls ;
133+ }
134+
135+ export function ChatList ( { chats, slug, whatsappId } : ChatListProps ) {
98136 const pathname = usePathname ( ) ;
99137 const [ searchQuery , setSearchQuery ] = useState ( "" ) ;
100138
@@ -121,6 +159,88 @@ export function ChatList({ chats, slug }: ChatListProps) {
121159 } ) ;
122160 } , [ chats , searchQuery ] ) ;
123161
162+ // All chats come from messages table, already sorted by most recent
163+ const displayChats = filteredChats ;
164+
165+ const PAGE_SIZE = 20 ;
166+ const [ visibleCount , setVisibleCount ] = useState ( PAGE_SIZE ) ;
167+ const sentinelRef = useRef < HTMLDivElement > ( null ) ;
168+
169+ // Reset visible count when search query changes
170+ useEffect ( ( ) => {
171+ setVisibleCount ( PAGE_SIZE ) ;
172+ } , [ searchQuery ] ) ;
173+
174+
175+ const isSearching = searchQuery . trim ( ) . length > 0 ;
176+ const paginatedChats = isSearching ? displayChats : displayChats . slice ( 0 , visibleCount ) ;
177+ const hasMore = ! isSearching && visibleCount < displayChats . length ;
178+
179+ // Track which chat items are visible on screen for lazy profile picture loading
180+ const [ visibleIds , setVisibleIds ] = useState < Set < string > > ( new Set ( ) ) ;
181+ const chatItemRefs = useRef < Map < string , HTMLElement > > ( new Map ( ) ) ;
182+ const visibilityObserverRef = useRef < IntersectionObserver | null > ( null ) ;
183+
184+ useEffect ( ( ) => {
185+ visibilityObserverRef . current = new IntersectionObserver (
186+ ( entries ) => {
187+ setVisibleIds ( ( prev ) => {
188+ const next = new Set ( prev ) ;
189+ let changed = false ;
190+ for ( const entry of entries ) {
191+ const id = ( entry . target as HTMLElement ) . dataset . chatIdentifier ;
192+ if ( ! id ) continue ;
193+ if ( entry . isIntersecting && ! next . has ( id ) ) {
194+ next . add ( id ) ;
195+ changed = true ;
196+ }
197+ }
198+ return changed ? next : prev ;
199+ } ) ;
200+ } ,
201+ { rootMargin : '100px' }
202+ ) ;
203+
204+ // Observe all currently registered refs
205+ for ( const el of chatItemRefs . current . values ( ) ) {
206+ visibilityObserverRef . current . observe ( el ) ;
207+ }
208+
209+ return ( ) => visibilityObserverRef . current ?. disconnect ( ) ;
210+ } , [ paginatedChats ] ) ;
211+
212+ const chatItemRef = useCallback ( ( identifier : string , el : HTMLElement | null ) => {
213+ if ( el ) {
214+ chatItemRefs . current . set ( identifier , el ) ;
215+ visibilityObserverRef . current ?. observe ( el ) ;
216+ } else {
217+ const prev = chatItemRefs . current . get ( identifier ) ;
218+ if ( prev ) visibilityObserverRef . current ?. unobserve ( prev ) ;
219+ chatItemRefs . current . delete ( identifier ) ;
220+ }
221+ } , [ ] ) ;
222+
223+ const visibleIdentifiers = useMemo ( ( ) => Array . from ( visibleIds ) , [ visibleIds ] ) ;
224+ const profilePicUrls = useProfilePictures ( whatsappId , visibleIdentifiers ) ;
225+
226+ // IntersectionObserver to load more chats when sentinel is visible
227+ useEffect ( ( ) => {
228+ const sentinel = sentinelRef . current ;
229+ if ( ! sentinel ) return ;
230+
231+ const observer = new IntersectionObserver (
232+ ( entries ) => {
233+ if ( entries [ 0 ] . isIntersecting && ! isSearching ) {
234+ setVisibleCount ( ( prev ) => Math . min ( prev + PAGE_SIZE , displayChats . length ) ) ;
235+ }
236+ } ,
237+ { rootMargin : '200px' }
238+ ) ;
239+
240+ observer . observe ( sentinel ) ;
241+ return ( ) => observer . disconnect ( ) ;
242+ } , [ displayChats . length , isSearching ] ) ;
243+
124244 return (
125245 < div className = "flex flex-col flex-1 min-h-0" >
126246 { /* Sticky Search Header */ }
@@ -138,11 +258,11 @@ export function ChatList({ chats, slug }: ChatListProps) {
138258
139259 < ScrollArea className = "flex-1 min-h-0 overflow-auto max-w-full" >
140260 < div className = "flex flex-col gap-1 p-2" >
141- { filteredChats . length === 0 ? (
261+ { paginatedChats . length === 0 ? (
142262 < div className = "text-center text-muted-foreground py-8 text-sm" >
143263 No se encontraron conversaciones
144264 </ div >
145- ) : filteredChats . map ( ( chat ) => {
265+ ) : paginatedChats . map ( ( chat ) => {
146266 const chatPath = `/whatsapp/${ slug } /chats/${ encodeURIComponent ( chat . identifier ) } ` ;
147267 const isActive = pathname === chatPath || pathname === decodeURIComponent ( chatPath ) ;
148268
@@ -151,24 +271,32 @@ export function ChatList({ chats, slug }: ChatListProps) {
151271 const isValidName = rawName && rawName . trim ( ) . length > 1 && rawName !== '.' ;
152272 const phoneNumber = chat . pn ?. split ( '@' ) [ 0 ] || chat . lid ?. split ( '@' ) [ 0 ] ;
153273 const displayName = isValidName ? rawName : ( phoneNumber || chat . identifier || '?' ) ;
274+ const profilePicUrl = profilePicUrls . get ( chat . identifier ) ;
154275
155276 return (
156277 < Link
157278 key = { chat . id }
158279 href = { chatPath }
280+ ref = { ( el ) => chatItemRef ( chat . identifier , el ) }
281+ data-chat-identifier = { chat . identifier }
159282 className = { cn (
160283 "flex items-center gap-3 p-3 rounded-lg transition-colors text-left max-w-[300px]" ,
161284 isActive ? "bg-accent" : "hover:bg-accent"
162285 ) }
163286 >
164- < Avatar >
165- < AvatarFallback suppressHydrationWarning > { displayName . slice ( 0 , 2 ) . toUpperCase ( ) } </ AvatarFallback >
166- </ Avatar >
287+ < div className = "relative shrink-0" >
288+ < Avatar >
289+ { profilePicUrl && (
290+ < AvatarImage src = { profilePicUrl } alt = { displayName } />
291+ ) }
292+ < AvatarFallback suppressHydrationWarning > { displayName . slice ( 0 , 2 ) . toUpperCase ( ) } </ AvatarFallback >
293+ </ Avatar >
294+ </ div >
167295 < div className = "flex-1 overflow-hidden min-w-0 w-full" >
168296 < div className = "flex items-center justify-between gap-2" >
169- < MarqueeText text = { displayName } className = "font-medium flex-1 min-w-0" />
297+ < MarqueeText text = { displayName } className = "flex-1 min-w-0 font-semibold " />
170298 < div className = "flex items-center gap-1 shrink-0" >
171- { chat . type === 'group' && < Badge variant = "secondary" className = "text-[10px] h-4 px-1" > Grupo</ Badge > }
299+ { chat . type === 'group' && < Badge variant = "secondary" className = "text-[10px] h-4 px-1 rounded-full " > Grupo</ Badge > }
172300 { chat . lastMessageAt && (
173301 < span className = "text-[10px] text-muted-foreground" suppressHydrationWarning >
174302 { formatMessageTime ( chat . lastMessageAt ) }
@@ -183,6 +311,16 @@ export function ChatList({ chats, slug }: ChatListProps) {
183311 </ Link >
184312 ) ;
185313 } ) }
314+
315+ { /* Sentinel for infinite scroll */ }
316+ < div ref = { sentinelRef } className = "h-1" />
317+ { hasMore && (
318+ < div className = "flex items-center justify-center gap-2 py-3 text-xs text-muted-foreground" >
319+ < Loader2 className = "h-3 w-3 animate-spin" />
320+ Cargando más...
321+ </ div >
322+ ) }
323+
186324 </ div >
187325 </ ScrollArea >
188326 </ div >
0 commit comments