Skip to content

Commit 2c296d0

Browse files
EijunnNclaude
andcommitted
feat: improve chat system, profile pictures, and rewrite README
- Chat list now built from messages table (shows all chats with messages, sorted by most recent) - Deduplicate contacts with multiple JIDs (lid/pn) to avoid duplicate entries - Infinite scroll pagination (20 chats at a time with IntersectionObserver) - Lazy-loaded profile pictures via new API endpoint (/api/whatsapp/[id]/profile-picture) - Fix hydration mismatch in AudioPlayer by replacing Math.random() with seeded PRNG - Add border to incoming messages for better visibility in dark mode - Add disappearingMessagesInChat: false to prevent "old version" warning - Rewrite README with use cases, architecture diagrams, API docs, and setup guide Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 67c94c4 commit 2c296d0

9 files changed

Lines changed: 587 additions & 362 deletions

File tree

README.md

Lines changed: 217 additions & 247 deletions
Large diffs are not rendered by default.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { auth } from "@/lib/auth";
2+
import { headers } from "next/headers";
3+
import { getSocket } from "@/lib/whatsapp";
4+
import { NextRequest, NextResponse } from "next/server";
5+
import { db } from "@/db";
6+
import { whatsappTable } from "@/db/schema";
7+
import { eq, and } from "drizzle-orm";
8+
9+
export async function GET(
10+
req: NextRequest,
11+
{ params }: { params: Promise<{ id: string }> }
12+
) {
13+
const { id } = await params;
14+
const session = await auth.api.getSession({
15+
headers: await headers(),
16+
});
17+
18+
if (!session) {
19+
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
20+
}
21+
22+
// Verify ownership
23+
const wa = await db.query.whatsappTable.findFirst({
24+
where: and(
25+
eq(whatsappTable.id, id),
26+
eq(whatsappTable.userId, session.user.id)
27+
),
28+
});
29+
30+
if (!wa) {
31+
return NextResponse.json({ error: "Not Found" }, { status: 404 });
32+
}
33+
34+
const jid = req.nextUrl.searchParams.get("jid");
35+
if (!jid) {
36+
return NextResponse.json(
37+
{ error: "Missing jid query parameter" },
38+
{ status: 400 }
39+
);
40+
}
41+
42+
const sock = getSocket(id);
43+
if (!sock) {
44+
return NextResponse.json(
45+
{ error: "WhatsApp instance not connected" },
46+
{ status: 503 }
47+
);
48+
}
49+
50+
let url: string | null = null;
51+
try {
52+
const result = await sock.profilePictureUrl(jid, "preview");
53+
url = result ?? null;
54+
} catch {
55+
// profilePictureUrl throws when there is no profile picture set
56+
url = null;
57+
}
58+
59+
return NextResponse.json(
60+
{ url },
61+
{
62+
headers: {
63+
"Cache-Control": "public, max-age=3600",
64+
},
65+
}
66+
);
67+
}

src/app/whatsapp/[slug]/chats/[chatId]/actions.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export async function sendMessageAction(
3030
throw new Error("WhatsApp is not connected");
3131
}
3232

33-
const result = await sock.sendMessage(chatId, { text: message });
33+
const result = await sock.sendMessage(chatId, { text: message, disappearingMessagesInChat: false });
3434

3535
// Save message to database with tracking
3636
if (result?.key?.id) {
@@ -125,6 +125,7 @@ export async function sendMediaMessageAction(
125125
};
126126
}
127127

128+
(messageContent as Record<string, unknown>).disappearingMessagesInChat = false;
128129
const result = await sock.sendMessage(chatId, messageContent as never);
129130

130131
// Save message to database with tracking

src/app/whatsapp/[slug]/chats/[chatId]/audio-player.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client';
22

3-
import { useState, useRef, useEffect } from "react";
3+
import { useState, useRef, useEffect, useMemo } from "react";
44
import { Play, Pause, Download } from "lucide-react";
55
import { cn } from "@/lib/utils";
66

@@ -17,6 +17,20 @@ function formatTime(seconds: number): string {
1717
return `${mins}:${secs.toString().padStart(2, '0')}`;
1818
}
1919

20+
// Deterministic pseudo-random based on a seed string (avoids hydration mismatch)
21+
function seededRandom(seed: string): () => number {
22+
let h = 0;
23+
for (let i = 0; i < seed.length; i++) {
24+
h = Math.imul(31, h) + seed.charCodeAt(i) | 0;
25+
}
26+
return () => {
27+
h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
28+
h = Math.imul(h ^ (h >>> 13), 0x45d9f3b);
29+
h = (h ^ (h >>> 16)) >>> 0;
30+
return (h % 1000) / 1000;
31+
};
32+
}
33+
2034
export function AudioPlayer({ src, fileName, fromMe }: AudioPlayerProps) {
2135
const audioRef = useRef<HTMLAudioElement>(null);
2236
const [isPlaying, setIsPlaying] = useState(false);
@@ -77,6 +91,12 @@ export function AudioPlayer({ src, fileName, fromMe }: AudioPlayerProps) {
7791

7892
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
7993

94+
// Generate deterministic waveform bars based on src URL
95+
const waveformBars = useMemo(() => {
96+
const rand = seededRandom(src);
97+
return Array.from({ length: 30 }, (_, i) => 20 + Math.sin(i * 0.5) * 15 + rand() * 10);
98+
}, [src]);
99+
80100
return (
81101
<div className="flex items-center gap-2 min-w-[200px] max-w-[280px]">
82102
<audio ref={audioRef} src={src} preload="metadata" />
@@ -110,8 +130,7 @@ export function AudioPlayer({ src, fileName, fromMe }: AudioPlayerProps) {
110130
>
111131
{/* Fake waveform bars */}
112132
<div className="absolute inset-0 flex items-center justify-around px-1 gap-[2px]">
113-
{Array.from({ length: 30 }).map((_, i) => {
114-
const height = 20 + Math.sin(i * 0.5) * 15 + Math.random() * 10;
133+
{waveformBars.map((height, i) => {
115134
const isPlayed = (i / 30) * 100 <= progress;
116135
return (
117136
<div

src/app/whatsapp/[slug]/chats/[chatId]/chat-messages.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export function ChatMessages({ initialMessages, chatId, slug, senderNames = {},
193193
"group relative w-fit max-w-[85%] md:max-w-[75%] flex-col gap-2 rounded-lg px-3 py-2 text-sm break-words [overflow-wrap:anywhere]",
194194
msg.fromMe
195195
? "ml-auto bg-primary text-primary-foreground"
196-
: "bg-muted"
196+
: "bg-card border border-border"
197197
)}
198198
>
199199
{/* Sender Name for Group Messages */}

src/app/whatsapp/[slug]/chats/chat-list.tsx

Lines changed: 149 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@
22

33
import Link from "next/link";
44
import { usePathname } from "next/navigation";
5-
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
5+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
66
import { Badge } from "@/components/ui/badge";
77
import { ScrollArea } from "@/components/ui/scroll-area";
88
import { Input } from "@/components/ui/input";
99
import { 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

1313
interface Chat {
1414
id: string;
@@ -27,6 +27,7 @@ interface Chat {
2727
interface ChatListProps {
2828
chats: Chat[];
2929
slug: string;
30+
whatsappId: string;
3031
}
3132

3233
function 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

Comments
 (0)