Skip to content

Commit 54df1dc

Browse files
authored
feat: infinite scroll on blabsy and pictique (#921)
* feat: infinite scroll on blabsy and pictique * fix: pictique message ordering * feat: loaders on load * fix: pictique loading state * chore: code rabbit fixes * chore: revert isOwn
1 parent 4bfa4b9 commit 54df1dc

File tree

6 files changed

+457
-139
lines changed

6 files changed

+457
-139
lines changed

platforms/blabsy/client/src/components/chat/chat-window.tsx

Lines changed: 68 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from 'react';
1+
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
22
import { useChat } from '@lib/context/chat-context';
33
import { useAuth } from '@lib/context/auth-context';
44
import { useWindow } from '@lib/context/window-context';
@@ -148,12 +148,18 @@ export function ChatWindow(): JSX.Element {
148148
sendNewMessage,
149149
markAsRead,
150150
loading,
151-
setCurrentChat
151+
setCurrentChat,
152+
hasMoreMessages,
153+
loadingOlderMessages,
154+
loadOlderMessages
152155
} = useChat();
153156
const { user } = useAuth();
154157
const { isMobile } = useWindow();
155158
const [messageText, setMessageText] = useState('');
156159
const messagesEndRef = useRef<HTMLDivElement>(null);
160+
const scrollContainerRef = useRef<HTMLDivElement>(null);
161+
const savedScrollInfo = useRef<{ scrollHeight: number; scrollTop: number } | null>(null);
162+
const prevNewestMessageId = useRef<string | null>(null);
157163
const [otherUser, setOtherUser] = useState<User | null>(null);
158164
const [participantsData, setParticipantsData] = useState<
159165
Record<string, User>
@@ -204,20 +210,7 @@ export function ChatWindow(): JSX.Element {
204210
otherParticipant &&
205211
newParticipantsData[otherParticipant]
206212
) {
207-
console.log(
208-
'ChatWindow: Setting otherUser:',
209-
newParticipantsData[otherParticipant]
210-
);
211213
setOtherUser(newParticipantsData[otherParticipant]);
212-
} else {
213-
console.log(
214-
'ChatWindow: Could not set otherUser. otherParticipant:',
215-
otherParticipant,
216-
'userData:',
217-
otherParticipant
218-
? newParticipantsData[otherParticipant]
219-
: 'undefined'
220-
);
221214
}
222215
}
223216
} catch (error) {
@@ -228,20 +221,44 @@ export function ChatWindow(): JSX.Element {
228221
void fetchParticipantsData();
229222
}, [currentChat, user]);
230223

224+
// Show loading until messages arrive for the current chat
231225
useEffect(() => {
232226
if (currentChat) {
233227
setIsLoading(true);
234-
// Simulate loading time for messages
235-
const timer = setTimeout(() => {
236-
setIsLoading(false);
237-
}, 500);
238-
return () => clearTimeout(timer);
228+
} else {
229+
setIsLoading(false);
239230
}
240231
}, [currentChat]);
241232

242233
useEffect(() => {
243-
if (messagesEndRef.current)
244-
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
234+
if (messages !== null && isLoading) {
235+
setIsLoading(false);
236+
}
237+
}, [messages, isLoading]);
238+
239+
// Auto-scroll to bottom only when a NEW message arrives (not older messages prepended)
240+
useEffect(() => {
241+
if (!messages || messages.length === 0) return;
242+
243+
// The messages array is sorted desc, so newest is at index 0
244+
const newestId = messages[0]?.id;
245+
246+
if (newestId !== prevNewestMessageId.current) {
247+
prevNewestMessageId.current = newestId;
248+
if (messagesEndRef.current) {
249+
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' });
250+
}
251+
}
252+
}, [messages]);
253+
254+
// Restore scroll position after older messages are prepended
255+
useLayoutEffect(() => {
256+
const el = scrollContainerRef.current;
257+
const saved = savedScrollInfo.current;
258+
if (el && saved) {
259+
el.scrollTop = el.scrollHeight - saved.scrollHeight + saved.scrollTop;
260+
savedScrollInfo.current = null;
261+
}
245262
}, [messages]);
246263

247264
useEffect(() => {
@@ -272,6 +289,20 @@ export function ChatWindow(): JSX.Element {
272289
}
273290
};
274291

292+
const handleScroll = (): void => {
293+
const el = scrollContainerRef.current;
294+
if (!el) return;
295+
296+
if (el.scrollTop < 100 && hasMoreMessages && !loadingOlderMessages) {
297+
// Save scroll position before loading older messages
298+
savedScrollInfo.current = {
299+
scrollHeight: el.scrollHeight,
300+
scrollTop: el.scrollTop
301+
};
302+
void loadOlderMessages();
303+
}
304+
};
305+
275306
return (
276307
<div className='flex h-full flex-col'>
277308
{currentChat ? (
@@ -356,13 +387,27 @@ export function ChatWindow(): JSX.Element {
356387
</div>
357388
)}
358389
</div>
359-
<div className='flex-1 p-4 overflow-y-auto'>
390+
<div
391+
ref={scrollContainerRef}
392+
onScroll={handleScroll}
393+
className='flex-1 p-4 overflow-y-auto'
394+
>
360395
{isLoading ? (
361396
<div className='flex h-full w-full items-center justify-center'>
362397
<Loading className='h-8 w-8' />
363398
</div>
364399
) : messages?.length ? (
365400
<div className='flex flex-col gap-2'>
401+
{!hasMoreMessages && messages.length > 0 && (
402+
<p className='text-center text-xs text-gray-400 dark:text-gray-500 my-2'>
403+
No more messages
404+
</p>
405+
)}
406+
{loadingOlderMessages && (
407+
<div className='flex justify-center my-2'>
408+
<Loading className='h-6 w-6' />
409+
</div>
410+
)}
366411
{[...messages]
367412
.reverse()
368413
.map((message, index, reversedMessages) => {
@@ -379,10 +424,6 @@ export function ChatWindow(): JSX.Element {
379424
nextMessage.senderId !==
380425
message.senderId;
381426

382-
// Show user info if:
383-
// 1. It's a group chat AND
384-
// 2. Previous message is from different sender OR doesn't exist OR
385-
// 3. Previous message is from same sender but more than 5 minutes ago
386427
const showUserInfo =
387428
getChatType(currentChat) ===
388429
'group' &&

platforms/blabsy/client/src/lib/context/chat-context.tsx

Lines changed: 100 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { useState, useEffect, useContext, createContext, useMemo } from 'react';
1+
import { useState, useEffect, useContext, createContext, useMemo, useCallback } from 'react';
22
import {
33
collection,
44
query,
55
where,
66
orderBy,
77
onSnapshot,
88
limit,
9-
Timestamp
9+
Timestamp,
10+
getDocs,
11+
startAfter,
12+
QueryDocumentSnapshot
1013
} from 'firebase/firestore';
1114
import { db } from '@lib/firebase/app';
1215
import {
@@ -25,18 +28,23 @@ import type { ReactNode } from 'react';
2528
import type { Chat } from '@lib/types/chat';
2629
import type { Message } from '@lib/types/message';
2730

31+
const MESSAGES_PER_PAGE = 30;
32+
2833
type ChatContext = {
2934
chats: Chat[] | null;
3035
currentChat: Chat | null;
3136
messages: Message[] | null;
3237
loading: boolean;
3338
error: Error | null;
39+
hasMoreMessages: boolean;
40+
loadingOlderMessages: boolean;
3441
setCurrentChat: (chat: Chat | null) => void;
35-
createNewChat: (participants: string[], name?: string) => Promise<string>;
42+
createNewChat: (participants: string[], name?: string, description?: string) => Promise<string>;
3643
sendNewMessage: (text: string) => Promise<void>;
3744
markAsRead: (messageId: string) => Promise<void>;
3845
addParticipant: (userId: string) => Promise<void>;
3946
removeParticipant: (userId: string) => Promise<void>;
47+
loadOlderMessages: () => Promise<void>;
4048
};
4149

4250
const ChatContext = createContext<ChatContext | null>(null);
@@ -51,15 +59,41 @@ export function ChatContextProvider({
5159
const { user } = useAuth();
5260
const [chats, setChats] = useState<Chat[] | null>(null);
5361
const [currentChat, setCurrentChat] = useState<Chat | null>(null);
54-
const [messages, setMessages] = useState<Message[] | null>(null);
62+
const [realtimeMessages, setRealtimeMessages] = useState<Message[] | null>(null);
63+
const [olderMessages, setOlderMessages] = useState<Message[]>([]);
5564
const [loading, setLoading] = useState(true);
5665
const [error, setError] = useState<Error | null>(null);
66+
const [hasMoreMessages, setHasMoreMessages] = useState(true);
67+
const [loadingOlderMessages, setLoadingOlderMessages] = useState(false);
68+
const [oldestDocSnapshot, setOldestDocSnapshot] = useState<QueryDocumentSnapshot | null>(null);
69+
70+
// Merge realtime + older messages, deduplicating by id
71+
const messages = useMemo(() => {
72+
if (!realtimeMessages) return null;
73+
74+
const messageMap = new Map<string, Message>();
75+
76+
// Add older messages first
77+
for (const msg of olderMessages) {
78+
messageMap.set(msg.id, msg);
79+
}
80+
81+
// Realtime messages overwrite any overlapping older ones
82+
for (const msg of realtimeMessages) {
83+
messageMap.set(msg.id, msg);
84+
}
85+
86+
// Sort descending by createdAt (matching the existing convention; UI reverses for display)
87+
return Array.from(messageMap.values()).sort((a, b) => {
88+
const aTime = a.createdAt?.toMillis?.() ?? 0;
89+
const bTime = b.createdAt?.toMillis?.() ?? 0;
90+
return bTime - aTime;
91+
});
92+
}, [realtimeMessages, olderMessages]);
5793

5894
// Listen to user's chats
5995
useEffect(() => {
6096
if (!user) {
61-
// setChats(null);
62-
// setLoading(false);
6397
setChats([
6498
{
6599
id: 'dummy-chat-1',
@@ -82,7 +116,7 @@ export function ChatContextProvider({
82116
updatedAt: Timestamp.fromDate(new Date()),
83117
lastMessage: {
84118
senderId: 'user_4',
85-
text: 'Lets meet tomorrow.',
119+
text: "Let's meet tomorrow.",
86120
timestamp: Timestamp.fromDate(new Date())
87121
},
88122
name: 'Project Team'
@@ -103,31 +137,23 @@ export function ChatContextProvider({
103137
(snapshot) => {
104138
const chatsData = snapshot.docs.map((doc) => doc.data());
105139

106-
// Sort chats by most recent activity (most recent first)
107-
// Priority: lastMessage timestamp > updatedAt > createdAt
108140
const sortedChats = chatsData.sort((a, b) => {
109-
// Get the most recent activity timestamp for each chat
110141
const getMostRecentTimestamp = (chat: typeof a): number => {
111-
// Priority 1: lastMessage timestamp (most recent activity)
112142
if (chat.lastMessage?.timestamp) {
113143
return chat.lastMessage.timestamp.toMillis();
114144
}
115-
// Priority 2: updatedAt (for updated chats without messages)
116145
if (chat.updatedAt) {
117146
return chat.updatedAt.toMillis();
118147
}
119-
// Priority 3: createdAt (for new chats)
120148
if (chat.createdAt) {
121149
return chat.createdAt.toMillis();
122150
}
123-
// Fallback: 0 for chats with no timestamps
124151
return 0;
125152
};
126153

127154
const aTimestamp = getMostRecentTimestamp(a);
128155
const bTimestamp = getMostRecentTimestamp(b);
129156

130-
// Sort by most recent timestamp (descending)
131157
return bTimestamp - aTimestamp;
132158
});
133159

@@ -147,24 +173,43 @@ export function ChatContextProvider({
147173
};
148174
}, [user]);
149175

150-
// Listen to current chat messages
176+
// Listen to current chat messages (realtime — most recent batch)
151177
useEffect(() => {
152178
if (!currentChat) {
153-
setMessages(null);
179+
setRealtimeMessages(null);
180+
setOlderMessages([]);
181+
setHasMoreMessages(true);
182+
setOldestDocSnapshot(null);
154183
return;
155184
}
156185

186+
// Reset pagination state on chat change
187+
setOlderMessages([]);
188+
setHasMoreMessages(true);
189+
setOldestDocSnapshot(null);
190+
157191
const messagesQuery = query(
158192
chatMessagesCollection(currentChat.id),
159193
orderBy('createdAt', 'desc'),
160-
limit(50)
194+
limit(MESSAGES_PER_PAGE)
161195
);
162196

163197
const unsubscribe = onSnapshot(
164198
messagesQuery,
165199
(snapshot) => {
166200
const messagesData = snapshot.docs.map((doc) => doc.data());
167-
setMessages(messagesData);
201+
setRealtimeMessages(messagesData);
202+
203+
// Store the oldest doc snapshot as cursor for pagination (only on first load)
204+
if (snapshot.docs.length > 0) {
205+
const lastDoc = snapshot.docs[snapshot.docs.length - 1];
206+
setOldestDocSnapshot((prev) => prev ?? lastDoc);
207+
}
208+
209+
// If we got fewer than the limit, there are no more messages
210+
if (snapshot.docs.length < MESSAGES_PER_PAGE) {
211+
setHasMoreMessages(false);
212+
}
168213
},
169214
(error) => {
170215
setError(error as Error);
@@ -176,6 +221,38 @@ export function ChatContextProvider({
176221
};
177222
}, [currentChat]);
178223

224+
const loadOlderMessages = useCallback(async (): Promise<void> => {
225+
if (!currentChat || !hasMoreMessages || loadingOlderMessages || !oldestDocSnapshot) return;
226+
227+
setLoadingOlderMessages(true);
228+
229+
try {
230+
const olderQuery = query(
231+
chatMessagesCollection(currentChat.id),
232+
orderBy('createdAt', 'desc'),
233+
startAfter(oldestDocSnapshot),
234+
limit(MESSAGES_PER_PAGE)
235+
);
236+
237+
const snapshot = await getDocs(olderQuery);
238+
const olderData = snapshot.docs.map((doc) => doc.data());
239+
240+
if (olderData.length > 0) {
241+
setOlderMessages((prev) => [...prev, ...olderData]);
242+
setOldestDocSnapshot(snapshot.docs[snapshot.docs.length - 1]);
243+
}
244+
245+
if (olderData.length < MESSAGES_PER_PAGE) {
246+
setHasMoreMessages(false);
247+
}
248+
} catch (error) {
249+
console.error('Error loading older messages:', error);
250+
setError(error as Error);
251+
}
252+
253+
setLoadingOlderMessages(false);
254+
}, [currentChat, hasMoreMessages, loadingOlderMessages, oldestDocSnapshot]);
255+
179256
const createNewChat = async (
180257
participants: string[],
181258
name?: string,
@@ -249,12 +326,15 @@ export function ChatContextProvider({
249326
messages,
250327
loading,
251328
error,
329+
hasMoreMessages,
330+
loadingOlderMessages,
252331
setCurrentChat,
253332
createNewChat,
254333
sendNewMessage,
255334
markAsRead,
256335
addParticipant,
257-
removeParticipant
336+
removeParticipant,
337+
loadOlderMessages
258338
};
259339

260340
return (

0 commit comments

Comments
 (0)