From 500bec688d36cf1ee64e7a58a0c35ee96e2cf404 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 16 Mar 2026 01:19:58 +0530 Subject: [PATCH 1/6] feat: infinite scroll on blabsy and pictique --- .../src/components/chat/chat-window.tsx | 82 +++++-- .../client/src/lib/context/chat-context.tsx | 118 ++++++++-- .../api/src/controllers/MessageController.ts | 40 +++- platforms/pictique/api/src/index.ts | 5 + .../pictique/api/src/services/ChatService.ts | 36 ++- .../(protected)/messages/[id]/+page.svelte | 210 +++++++++++------- 6 files changed, 368 insertions(+), 123 deletions(-) diff --git a/platforms/blabsy/client/src/components/chat/chat-window.tsx b/platforms/blabsy/client/src/components/chat/chat-window.tsx index c8cdd8436..385cac0f2 100644 --- a/platforms/blabsy/client/src/components/chat/chat-window.tsx +++ b/platforms/blabsy/client/src/components/chat/chat-window.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useChat } from '@lib/context/chat-context'; import { useAuth } from '@lib/context/auth-context'; import { useWindow } from '@lib/context/window-context'; @@ -148,12 +148,18 @@ export function ChatWindow(): JSX.Element { sendNewMessage, markAsRead, loading, - setCurrentChat + setCurrentChat, + hasMoreMessages, + loadingOlderMessages, + loadOlderMessages } = useChat(); const { user } = useAuth(); const { isMobile } = useWindow(); const [messageText, setMessageText] = useState(''); const messagesEndRef = useRef(null); + const scrollContainerRef = useRef(null); + const savedScrollInfo = useRef<{ scrollHeight: number; scrollTop: number } | null>(null); + const prevNewestMessageId = useRef(null); const [otherUser, setOtherUser] = useState(null); const [participantsData, setParticipantsData] = useState< Record @@ -204,20 +210,7 @@ export function ChatWindow(): JSX.Element { otherParticipant && newParticipantsData[otherParticipant] ) { - console.log( - 'ChatWindow: Setting otherUser:', - newParticipantsData[otherParticipant] - ); setOtherUser(newParticipantsData[otherParticipant]); - } else { - console.log( - 'ChatWindow: Could not set otherUser. otherParticipant:', - otherParticipant, - 'userData:', - otherParticipant - ? newParticipantsData[otherParticipant] - : 'undefined' - ); } } } catch (error) { @@ -231,7 +224,6 @@ export function ChatWindow(): JSX.Element { useEffect(() => { if (currentChat) { setIsLoading(true); - // Simulate loading time for messages const timer = setTimeout(() => { setIsLoading(false); }, 500); @@ -239,9 +231,29 @@ export function ChatWindow(): JSX.Element { } }, [currentChat]); + // Auto-scroll to bottom only when a NEW message arrives (not older messages prepended) useEffect(() => { - if (messagesEndRef.current) - messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + if (!messages || messages.length === 0) return; + + // The messages array is sorted desc, so newest is at index 0 + const newestId = messages[0]?.id; + + if (newestId !== prevNewestMessageId.current) { + prevNewestMessageId.current = newestId; + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: 'smooth' }); + } + } + }, [messages]); + + // Restore scroll position after older messages are prepended + useLayoutEffect(() => { + const el = scrollContainerRef.current; + const saved = savedScrollInfo.current; + if (el && saved) { + el.scrollTop = el.scrollHeight - saved.scrollHeight + saved.scrollTop; + savedScrollInfo.current = null; + } }, [messages]); useEffect(() => { @@ -272,6 +284,20 @@ export function ChatWindow(): JSX.Element { } }; + const handleScroll = (): void => { + const el = scrollContainerRef.current; + if (!el) return; + + if (el.scrollTop < 100 && hasMoreMessages && !loadingOlderMessages) { + // Save scroll position before loading older messages + savedScrollInfo.current = { + scrollHeight: el.scrollHeight, + scrollTop: el.scrollTop + }; + void loadOlderMessages(); + } + }; + return (
{currentChat ? ( @@ -356,13 +382,27 @@ export function ChatWindow(): JSX.Element {
)} -
+
{isLoading ? (
) : messages?.length ? (
+ {!hasMoreMessages && ( +

+ No more messages +

+ )} + {loadingOlderMessages && ( +
+ +
+ )} {[...messages] .reverse() .map((message, index, reversedMessages) => { @@ -379,10 +419,6 @@ export function ChatWindow(): JSX.Element { nextMessage.senderId !== message.senderId; - // Show user info if: - // 1. It's a group chat AND - // 2. Previous message is from different sender OR doesn't exist OR - // 3. Previous message is from same sender but more than 5 minutes ago const showUserInfo = getChatType(currentChat) === 'group' && diff --git a/platforms/blabsy/client/src/lib/context/chat-context.tsx b/platforms/blabsy/client/src/lib/context/chat-context.tsx index caaf45758..7077ff7b1 100644 --- a/platforms/blabsy/client/src/lib/context/chat-context.tsx +++ b/platforms/blabsy/client/src/lib/context/chat-context.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useContext, createContext, useMemo } from 'react'; +import { useState, useEffect, useContext, createContext, useMemo, useCallback } from 'react'; import { collection, query, @@ -6,7 +6,10 @@ import { orderBy, onSnapshot, limit, - Timestamp + Timestamp, + getDocs, + startAfter, + QueryDocumentSnapshot } from 'firebase/firestore'; import { db } from '@lib/firebase/app'; import { @@ -25,18 +28,23 @@ import type { ReactNode } from 'react'; import type { Chat } from '@lib/types/chat'; import type { Message } from '@lib/types/message'; +const MESSAGES_PER_PAGE = 30; + type ChatContext = { chats: Chat[] | null; currentChat: Chat | null; messages: Message[] | null; loading: boolean; error: Error | null; + hasMoreMessages: boolean; + loadingOlderMessages: boolean; setCurrentChat: (chat: Chat | null) => void; createNewChat: (participants: string[], name?: string) => Promise; sendNewMessage: (text: string) => Promise; markAsRead: (messageId: string) => Promise; addParticipant: (userId: string) => Promise; removeParticipant: (userId: string) => Promise; + loadOlderMessages: () => Promise; }; const ChatContext = createContext(null); @@ -51,15 +59,41 @@ export function ChatContextProvider({ const { user } = useAuth(); const [chats, setChats] = useState(null); const [currentChat, setCurrentChat] = useState(null); - const [messages, setMessages] = useState(null); + const [realtimeMessages, setRealtimeMessages] = useState(null); + const [olderMessages, setOlderMessages] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [hasMoreMessages, setHasMoreMessages] = useState(true); + const [loadingOlderMessages, setLoadingOlderMessages] = useState(false); + const [oldestDocSnapshot, setOldestDocSnapshot] = useState(null); + + // Merge realtime + older messages, deduplicating by id + const messages = useMemo(() => { + if (!realtimeMessages) return null; + + const messageMap = new Map(); + + // Add older messages first + for (const msg of olderMessages) { + messageMap.set(msg.id, msg); + } + + // Realtime messages overwrite any overlapping older ones + for (const msg of realtimeMessages) { + messageMap.set(msg.id, msg); + } + + // Sort descending by createdAt (matching the existing convention; UI reverses for display) + return Array.from(messageMap.values()).sort((a, b) => { + const aTime = a.createdAt?.toMillis?.() ?? 0; + const bTime = b.createdAt?.toMillis?.() ?? 0; + return bTime - aTime; + }); + }, [realtimeMessages, olderMessages]); // Listen to user's chats useEffect(() => { if (!user) { - // setChats(null); - // setLoading(false); setChats([ { id: 'dummy-chat-1', @@ -82,7 +116,7 @@ export function ChatContextProvider({ updatedAt: Timestamp.fromDate(new Date()), lastMessage: { senderId: 'user_4', - text: 'Let’s meet tomorrow.', + text: 'Let's meet tomorrow.', timestamp: Timestamp.fromDate(new Date()) }, name: 'Project Team' @@ -103,31 +137,23 @@ export function ChatContextProvider({ (snapshot) => { const chatsData = snapshot.docs.map((doc) => doc.data()); - // Sort chats by most recent activity (most recent first) - // Priority: lastMessage timestamp > updatedAt > createdAt const sortedChats = chatsData.sort((a, b) => { - // Get the most recent activity timestamp for each chat const getMostRecentTimestamp = (chat: typeof a): number => { - // Priority 1: lastMessage timestamp (most recent activity) if (chat.lastMessage?.timestamp) { return chat.lastMessage.timestamp.toMillis(); } - // Priority 2: updatedAt (for updated chats without messages) if (chat.updatedAt) { return chat.updatedAt.toMillis(); } - // Priority 3: createdAt (for new chats) if (chat.createdAt) { return chat.createdAt.toMillis(); } - // Fallback: 0 for chats with no timestamps return 0; }; const aTimestamp = getMostRecentTimestamp(a); const bTimestamp = getMostRecentTimestamp(b); - // Sort by most recent timestamp (descending) return bTimestamp - aTimestamp; }); @@ -147,24 +173,43 @@ export function ChatContextProvider({ }; }, [user]); - // Listen to current chat messages + // Listen to current chat messages (realtime β€” most recent batch) useEffect(() => { if (!currentChat) { - setMessages(null); + setRealtimeMessages(null); + setOlderMessages([]); + setHasMoreMessages(true); + setOldestDocSnapshot(null); return; } + // Reset pagination state on chat change + setOlderMessages([]); + setHasMoreMessages(true); + setOldestDocSnapshot(null); + const messagesQuery = query( chatMessagesCollection(currentChat.id), orderBy('createdAt', 'desc'), - limit(50) + limit(MESSAGES_PER_PAGE) ); const unsubscribe = onSnapshot( messagesQuery, (snapshot) => { const messagesData = snapshot.docs.map((doc) => doc.data()); - setMessages(messagesData); + setRealtimeMessages(messagesData); + + // Store the oldest doc snapshot as cursor for pagination (only on first load) + if (snapshot.docs.length > 0) { + const lastDoc = snapshot.docs[snapshot.docs.length - 1]; + setOldestDocSnapshot((prev) => prev ?? lastDoc); + } + + // If we got fewer than the limit, there are no more messages + if (snapshot.docs.length < MESSAGES_PER_PAGE) { + setHasMoreMessages(false); + } }, (error) => { setError(error as Error); @@ -176,6 +221,38 @@ export function ChatContextProvider({ }; }, [currentChat]); + const loadOlderMessages = useCallback(async (): Promise => { + if (!currentChat || !hasMoreMessages || loadingOlderMessages || !oldestDocSnapshot) return; + + setLoadingOlderMessages(true); + + try { + const olderQuery = query( + chatMessagesCollection(currentChat.id), + orderBy('createdAt', 'desc'), + startAfter(oldestDocSnapshot), + limit(MESSAGES_PER_PAGE) + ); + + const snapshot = await getDocs(olderQuery); + const olderData = snapshot.docs.map((doc) => doc.data()); + + if (olderData.length > 0) { + setOlderMessages((prev) => [...prev, ...olderData]); + setOldestDocSnapshot(snapshot.docs[snapshot.docs.length - 1]); + } + + if (olderData.length < MESSAGES_PER_PAGE) { + setHasMoreMessages(false); + } + } catch (error) { + console.error('Error loading older messages:', error); + setError(error as Error); + } + + setLoadingOlderMessages(false); + }, [currentChat, hasMoreMessages, loadingOlderMessages, oldestDocSnapshot]); + const createNewChat = async ( participants: string[], name?: string, @@ -249,12 +326,15 @@ export function ChatContextProvider({ messages, loading, error, + hasMoreMessages, + loadingOlderMessages, setCurrentChat, createNewChat, sendNewMessage, markAsRead, addParticipant, - removeParticipant + removeParticipant, + loadOlderMessages }; return ( diff --git a/platforms/pictique/api/src/controllers/MessageController.ts b/platforms/pictique/api/src/controllers/MessageController.ts index 9f870e5fd..8592e13c1 100644 --- a/platforms/pictique/api/src/controllers/MessageController.ts +++ b/platforms/pictique/api/src/controllers/MessageController.ts @@ -216,6 +216,35 @@ export class MessageController { } }; + getMessagesBefore = async (req: Request, res: Response) => { + try { + const { chatId } = req.params; + const userId = req.user?.id; + const beforeId = req.query.before as string; + const limit = parseInt(req.query.limit as string) || 30; + + if (!userId) { + return res.status(401).json({ error: "Unauthorized" }); + } + + if (!beforeId) { + return res + .status(400) + .json({ error: "before parameter is required" }); + } + + const result = await this.chatService.getChatMessagesBefore( + chatId, + beforeId, + limit + ); + res.json(result); + } catch (error) { + console.error("Error fetching older messages:", error); + res.status(500).json({ error: "Internal server error" }); + } + }; + markAsRead = async (req: Request, res: Response) => { try { const { chatId } = req.params; @@ -309,7 +338,7 @@ export class MessageController { }); const page = parseInt(req.query.page as string) || 1; - const limit = parseInt(req.query.limit as string) || 2000; + const limit = parseInt(req.query.limit as string) || 30; if (!userId) { return res.status(401).json({ error: "Unauthorized" }); @@ -323,8 +352,13 @@ export class MessageController { limit ); - // Send initial connection message - res.write(`data: ${JSON.stringify(messages.messages)}\n\n`); + // Send initial connection message with metadata + res.write(`data: ${JSON.stringify({ + type: "initial", + messages: messages.messages, + total: messages.total, + hasMore: messages.total > limit, + })}\n\n`); // Create event listener for this chat const eventEmitter = this.chatService.getEventEmitter(); diff --git a/platforms/pictique/api/src/index.ts b/platforms/pictique/api/src/index.ts index caa0c0623..894707073 100644 --- a/platforms/pictique/api/src/index.ts +++ b/platforms/pictique/api/src/index.ts @@ -121,6 +121,11 @@ app.get( authGuard, messageController.getMessages, ); +app.get( + "/api/chats/:chatId/messages/before", + authGuard, + messageController.getMessagesBefore, +); app.delete( "/api/chats/:chatId/messages/:messageId", authGuard, diff --git a/platforms/pictique/api/src/services/ChatService.ts b/platforms/pictique/api/src/services/ChatService.ts index 7fb4d11ef..4bd0ea144 100644 --- a/platforms/pictique/api/src/services/ChatService.ts +++ b/platforms/pictique/api/src/services/ChatService.ts @@ -3,7 +3,7 @@ import { Chat } from "../database/entities/Chat"; import { Message } from "../database/entities/Message"; import { User } from "../database/entities/User"; import { MessageReadStatus } from "../database/entities/MessageReadStatus"; -import { In } from "typeorm"; +import { In, LessThan } from "typeorm"; import { EventEmitter } from "events"; import { emitter } from "./event-emitter"; @@ -267,6 +267,40 @@ export class ChatService { }; } + async getChatMessagesBefore( + chatId: string, + beforeId: string, + limit: number = 30 + ): Promise<{ + messages: Message[]; + hasMore: boolean; + }> { + const cursorMessage = await this.messageRepository.findOne({ + where: { id: beforeId, chat: { id: chatId } }, + }); + if (!cursorMessage) { + throw new Error("Cursor message not found"); + } + + const messages = await this.messageRepository.find({ + where: { + chat: { id: chatId }, + createdAt: LessThan(cursorMessage.createdAt), + }, + relations: ["sender", "readStatuses", "readStatuses.user"], + order: { createdAt: "DESC" }, + take: limit + 1, + }); + + const hasMore = messages.length > limit; + const resultMessages = hasMore ? messages.slice(0, limit) : messages; + + return { + messages: resultMessages.reverse(), + hasMore, + }; + } + async markMessagesAsRead(chatId: string, userId: string): Promise { const chat = await this.getChatById(chatId); if (!chat) { diff --git a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte index 92ab7630f..0cabf6e1b 100644 --- a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte @@ -4,7 +4,7 @@ import { ChatMessage, MessageInput } from '$lib/fragments'; import { apiClient, getAuthToken } from '$lib/utils/axios'; import moment from 'moment'; - import { onMount } from 'svelte'; + import { onMount, tick } from 'svelte'; import { heading } from '../../../store'; import type { Chat } from '$lib/types'; @@ -14,6 +14,10 @@ let messageValue = $state(''); let messagesContainer: HTMLDivElement; let historyLoaded = $state(false); + let hasMore = $state(true); + let loadingOlder = $state(false); + let oldestMessageId = $state(null); + let shouldScrollToBottom = $state(false); // Function to remove duplicate messages by ID function removeDuplicateMessages( @@ -23,7 +27,6 @@ return messagesArray.filter((msg) => { const id = msg.id as string; if (seen.has(id)) { - console.log(`Removing duplicate message with ID: ${id}`); return false; } seen.add(id); @@ -37,75 +40,34 @@ } } - // Scroll to bottom when messages change + // Scroll to bottom only when new messages arrive (not when older messages are prepended) $effect(() => { - if (messages) { - // Use setTimeout to ensure DOM has updated + if (shouldScrollToBottom) { setTimeout(scrollToBottom, 0); + shouldScrollToBottom = false; } }); - async function watchEventStream() { - const sseUrl = new URL( - `/api/chats/${id}/events?token=${getAuthToken()}`, - PUBLIC_PICTIQUE_BASE_URL - ).toString(); - const eventSource = new EventSource(sseUrl); - - eventSource.onopen = () => { - console.log('Successfully connected.'); - historyLoaded = true; - }; - - eventSource.onmessage = (e) => { - try { - const data = JSON.parse(e.data); - console.log('πŸ“¨ SSE message received:', data); - console.log('Current messages count before adding:', messages.length); - addMessages(data); - console.log('Messages count after adding:', messages.length); - // Use setTimeout to ensure DOM has updated - setTimeout(scrollToBottom, 0); - } catch (error) { - console.error('Error parsing SSE message:', error); - } - }; - } - - async function handleSend() { - await apiClient.post(`/api/chats/${id}/messages`, { - text: messageValue - }); - messageValue = ''; - } - - function addMessages(arr: Record[]) { - console.log('Raw messages:', arr); - console.log('Current userId:', userId); - - const newMessages = arr.map((m) => { - // Check if this is a system message (no sender) + // Transform raw API messages into display format + function transformMessages(arr: Record[]): Record[] { + return arr.map((m) => { const isSystemMessage = !m.sender || m.text?.toString().startsWith('$$system-message$$'); if (isSystemMessage) { - // Handle system messages - they don't have a sender return { id: m.id, - isOwn: false, // System messages are not "owned" by any user - userImgSrc: '/images/system-message.png', // Default system message icon + isOwn: false, + userImgSrc: '/images/system-message.png', time: moment(m.createdAt as string).fromNow(), message: m.text, isSystemMessage: true }; } - // Handle regular user messages const sender = m.sender as Record; const isOwn = sender.id !== userId; - console.log('Message sender ID:', sender.id, 'User ID:', userId, 'IsOwn:', isOwn); - return { id: m.id, isOwn: isOwn, @@ -118,26 +80,30 @@ senderHandle: sender.handle }; }); - apiClient.post(`/api/chats/${id}/messages/read`); + } - // Process messages to determine which ones need heads and timestamps - const processedMessages = newMessages.map((msg, index) => { - const prevMessage = index > 0 ? newMessages[index - 1] : null; - const nextMessage = index < newMessages.length - 1 ? newMessages[index + 1] : null; + // Compute head/timestamp flags for the full messages array + function computeGroupFlags( + messagesArray: Record[] + ): Record[] { + return messagesArray.map((msg, index) => { + const prevMessage = index > 0 ? messagesArray[index - 1] : null; + const nextMessage = + index < messagesArray.length - 1 ? messagesArray[index + 1] : null; - // Show head (avatar, pointer) on first message of group - // Check if isOwn changed OR if the sender changed (for non-own messages) const isHeadNeeded = !prevMessage || prevMessage.isOwn !== msg.isOwn || - (prevMessage.senderId && msg.senderId && prevMessage.senderId !== msg.senderId); + (prevMessage.senderId && + msg.senderId && + prevMessage.senderId !== msg.senderId); - // Show timestamp on last message of group - // Check if isOwn will change OR if the sender will change (for non-own messages) const isTimestampNeeded = !nextMessage || nextMessage.isOwn !== msg.isOwn || - (nextMessage.senderId && msg.senderId && nextMessage.senderId !== msg.senderId); + (nextMessage.senderId && + msg.senderId && + nextMessage.senderId !== msg.senderId); return { ...msg, @@ -145,24 +111,103 @@ isTimestampNeeded }; }); + } + + async function watchEventStream() { + const sseUrl = new URL( + `/api/chats/${id}/events?token=${getAuthToken()}`, + PUBLIC_PICTIQUE_BASE_URL + ).toString(); + const eventSource = new EventSource(sseUrl); + + eventSource.onopen = () => { + console.log('Successfully connected.'); + historyLoaded = true; + }; + + eventSource.onmessage = (e) => { + try { + const data = JSON.parse(e.data); + + // Handle initial payload with metadata vs real-time new messages + if (data.type === 'initial') { + // Initial batch from SSE connection + const transformed = transformMessages(data.messages); + messages = computeGroupFlags(removeDuplicateMessages(transformed)); + hasMore = data.hasMore; + if (data.messages.length > 0) { + oldestMessageId = data.messages[0].id; + } + shouldScrollToBottom = true; + } else { + // Real-time new message(s) β€” could be array or wrapped + const rawMessages = Array.isArray(data) ? data : [data]; + const transformed = transformMessages(rawMessages); + + const existingIds = new Set(messages.map((msg) => msg.id)); + const uniqueNew = transformed.filter((msg) => !existingIds.has(msg.id)); + + if (uniqueNew.length > 0) { + const merged = removeDuplicateMessages([...messages, ...uniqueNew]); + messages = computeGroupFlags(merged); + shouldScrollToBottom = true; + } + } + + apiClient.post(`/api/chats/${id}/messages/read`); + } catch (error) { + console.error('Error parsing SSE message:', error); + } + }; + } + + async function handleSend() { + await apiClient.post(`/api/chats/${id}/messages`, { + text: messageValue + }); + messageValue = ''; + } + + async function loadOlderMessages() { + if (!oldestMessageId || loadingOlder || !hasMore) return; + loadingOlder = true; + + const savedScrollHeight = messagesContainer.scrollHeight; + const savedScrollTop = messagesContainer.scrollTop; + + try { + const { data } = await apiClient.get( + `/api/chats/${id}/messages/before?before=${oldestMessageId}&limit=30` + ); - // Prevent duplicate messages by checking IDs - const existingIds = new Set(messages.map((msg) => msg.id)); - const uniqueNewMessages = processedMessages.filter((msg) => !existingIds.has(msg.id)); - - if (uniqueNewMessages.length > 0) { - console.log(`Adding ${uniqueNewMessages.length} new unique messages`); - const newMessagesArray = messages.concat(uniqueNewMessages); - // Final safety check to remove any duplicates - messages = removeDuplicateMessages(newMessagesArray); - } else { - console.log('No new unique messages to add'); + if (data.messages.length > 0) { + const transformed = transformMessages(data.messages); + const merged = removeDuplicateMessages([...transformed, ...messages]); + messages = computeGroupFlags(merged); + oldestMessageId = data.messages[0].id; + + // Restore scroll position after DOM update + await tick(); + messagesContainer.scrollTop = + messagesContainer.scrollHeight - savedScrollHeight + savedScrollTop; + } + + hasMore = data.hasMore; + } catch (error) { + console.error('Failed to load older messages:', error); + } + + loadingOlder = false; + } + + function handleScroll() { + if (messagesContainer.scrollTop < 100 && hasMore && !loadingOlder) { + loadOlderMessages(); } } async function loadChatInfo() { try { - // Load chat info to set the header correctly const { data: chatsData } = await apiClient.get<{ chats: Chat[]; }>(`/api/chats?page=1&limit=100`); @@ -172,7 +217,6 @@ const members = chat.participants.filter((u) => u.id !== userId); const isGroup = members.length > 1; - // For 2-person chats, show the other person's name, not the group name const displayName = isGroup ? chat.name || members.map((m) => m.name ?? m.handle ?? m.ename).join(', ') : members[0]?.name || members[0]?.handle || members[0]?.ename || 'Unknown User'; @@ -193,11 +237,23 @@
-
+
+ {#if loadingOlder} +
+ +
+ {/if} + {#if !hasMore && messages.length > 0} +

No more messages

+ {/if} {#if historyLoaded && messages.length === 0}

No messages yet. Start the conversation!

{/if} - {#each removeDuplicateMessages(messages) as msg (msg.id)} + {#each messages as msg (msg.id)} Date: Mon, 16 Mar 2026 01:25:12 +0530 Subject: [PATCH 2/6] fix: pictique message ordering --- .../api/src/controllers/MessageController.ts | 17 +++++--------- .../pictique/api/src/services/ChatService.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/platforms/pictique/api/src/controllers/MessageController.ts b/platforms/pictique/api/src/controllers/MessageController.ts index 8592e13c1..b66f2529f 100644 --- a/platforms/pictique/api/src/controllers/MessageController.ts +++ b/platforms/pictique/api/src/controllers/MessageController.ts @@ -337,27 +337,20 @@ export class MessageController { Connection: "keep-alive", }); - const page = parseInt(req.query.page as string) || 1; const limit = parseInt(req.query.limit as string) || 30; - if (!userId) { - return res.status(401).json({ error: "Unauthorized" }); - } - - // Get messages for the chat - const messages = await this.chatService.getChatMessages( + // Get the most recent messages for the chat + const result = await this.chatService.getLatestMessages( chatId, - userId, - page, limit ); // Send initial connection message with metadata res.write(`data: ${JSON.stringify({ type: "initial", - messages: messages.messages, - total: messages.total, - hasMore: messages.total > limit, + messages: result.messages, + total: result.total, + hasMore: result.hasMore, })}\n\n`); // Create event listener for this chat diff --git a/platforms/pictique/api/src/services/ChatService.ts b/platforms/pictique/api/src/services/ChatService.ts index 4bd0ea144..b69d379bf 100644 --- a/platforms/pictique/api/src/services/ChatService.ts +++ b/platforms/pictique/api/src/services/ChatService.ts @@ -267,6 +267,28 @@ export class ChatService { }; } + async getLatestMessages( + chatId: string, + limit: number = 30 + ): Promise<{ + messages: Message[]; + total: number; + hasMore: boolean; + }> { + const [messages, total] = await this.messageRepository.findAndCount({ + where: { chat: { id: chatId } }, + relations: ["sender", "readStatuses", "readStatuses.user"], + order: { createdAt: "DESC" }, + take: limit, + }); + + return { + messages: messages.reverse(), // Return in ASC order for display + total, + hasMore: total > limit, + }; + } + async getChatMessagesBefore( chatId: string, beforeId: string, From d533e31bd8fb753fad5301cfd5657a9fb6f95e98 Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 16 Mar 2026 01:28:03 +0530 Subject: [PATCH 3/6] feat: loaders on load --- .../src/components/chat/chat-window.tsx | 13 ++++++---- .../(protected)/messages/[id]/+page.svelte | 24 ++++++++++++------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/platforms/blabsy/client/src/components/chat/chat-window.tsx b/platforms/blabsy/client/src/components/chat/chat-window.tsx index 385cac0f2..dbe8f4541 100644 --- a/platforms/blabsy/client/src/components/chat/chat-window.tsx +++ b/platforms/blabsy/client/src/components/chat/chat-window.tsx @@ -221,16 +221,21 @@ export function ChatWindow(): JSX.Element { void fetchParticipantsData(); }, [currentChat, user]); + // Show loading until messages arrive for the current chat useEffect(() => { if (currentChat) { setIsLoading(true); - const timer = setTimeout(() => { - setIsLoading(false); - }, 500); - return () => clearTimeout(timer); + } else { + setIsLoading(false); } }, [currentChat]); + useEffect(() => { + if (messages !== null && isLoading) { + setIsLoading(false); + } + }, [messages, isLoading]); + // Auto-scroll to bottom only when a NEW message arrives (not older messages prepended) useEffect(() => { if (!messages || messages.length === 0) return; diff --git a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte index 0cabf6e1b..cad2d8970 100644 --- a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte @@ -242,16 +242,22 @@ bind:this={messagesContainer} onscroll={handleScroll} > - {#if loadingOlder} -
- + {#if !historyLoaded} +
+
- {/if} - {#if !hasMore && messages.length > 0} -

No more messages

- {/if} - {#if historyLoaded && messages.length === 0} -

No messages yet. Start the conversation!

+ {:else} + {#if loadingOlder} +
+ +
+ {/if} + {#if !hasMore && messages.length > 0} +

No more messages

+ {/if} + {#if messages.length === 0} +

No messages yet. Start the conversation!

+ {/if} {/if} {#each messages as msg (msg.id)} Date: Mon, 16 Mar 2026 01:32:15 +0530 Subject: [PATCH 4/6] fix: pictique loading state --- platforms/blabsy/client/src/lib/context/chat-context.tsx | 2 +- .../src/routes/(protected)/messages/[id]/+page.svelte | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/platforms/blabsy/client/src/lib/context/chat-context.tsx b/platforms/blabsy/client/src/lib/context/chat-context.tsx index 7077ff7b1..e5c4f4de6 100644 --- a/platforms/blabsy/client/src/lib/context/chat-context.tsx +++ b/platforms/blabsy/client/src/lib/context/chat-context.tsx @@ -116,7 +116,7 @@ export function ChatContextProvider({ updatedAt: Timestamp.fromDate(new Date()), lastMessage: { senderId: 'user_4', - text: 'Let's meet tomorrow.', + text: "Let's meet tomorrow.", timestamp: Timestamp.fromDate(new Date()) }, name: 'Project Team' diff --git a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte index cad2d8970..252c11d49 100644 --- a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte @@ -122,7 +122,6 @@ eventSource.onopen = () => { console.log('Successfully connected.'); - historyLoaded = true; }; eventSource.onmessage = (e) => { @@ -138,6 +137,7 @@ if (data.messages.length > 0) { oldestMessageId = data.messages[0].id; } + historyLoaded = true; shouldScrollToBottom = true; } else { // Real-time new message(s) β€” could be array or wrapped @@ -244,12 +244,12 @@ > {#if !historyLoaded}
- +
{:else} {#if loadingOlder}
- +
{/if} {#if !hasMore && messages.length > 0} From 9036d5d5cf64d50f6381d8986f876940d39203ef Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 16 Mar 2026 02:09:22 +0530 Subject: [PATCH 5/6] chore: code rabbit fixes --- .../src/components/chat/chat-window.tsx | 2 +- .../client/src/lib/context/chat-context.tsx | 2 +- .../api/src/controllers/MessageController.ts | 11 ++++++ .../pictique/api/src/services/ChatService.ts | 34 +++++++++++++------ .../(protected)/messages/[id]/+page.svelte | 28 +++++++++++++-- 5 files changed, 62 insertions(+), 15 deletions(-) diff --git a/platforms/blabsy/client/src/components/chat/chat-window.tsx b/platforms/blabsy/client/src/components/chat/chat-window.tsx index dbe8f4541..235d4f063 100644 --- a/platforms/blabsy/client/src/components/chat/chat-window.tsx +++ b/platforms/blabsy/client/src/components/chat/chat-window.tsx @@ -398,7 +398,7 @@ export function ChatWindow(): JSX.Element {
) : messages?.length ? (
- {!hasMoreMessages && ( + {!hasMoreMessages && messages.length > 0 && (

No more messages

diff --git a/platforms/blabsy/client/src/lib/context/chat-context.tsx b/platforms/blabsy/client/src/lib/context/chat-context.tsx index e5c4f4de6..626bdcfa7 100644 --- a/platforms/blabsy/client/src/lib/context/chat-context.tsx +++ b/platforms/blabsy/client/src/lib/context/chat-context.tsx @@ -39,7 +39,7 @@ type ChatContext = { hasMoreMessages: boolean; loadingOlderMessages: boolean; setCurrentChat: (chat: Chat | null) => void; - createNewChat: (participants: string[], name?: string) => Promise; + createNewChat: (participants: string[], name?: string, description?: string) => Promise; sendNewMessage: (text: string) => Promise; markAsRead: (messageId: string) => Promise; addParticipant: (userId: string) => Promise; diff --git a/platforms/pictique/api/src/controllers/MessageController.ts b/platforms/pictique/api/src/controllers/MessageController.ts index b66f2529f..a35d915f2 100644 --- a/platforms/pictique/api/src/controllers/MessageController.ts +++ b/platforms/pictique/api/src/controllers/MessageController.ts @@ -233,6 +233,17 @@ export class MessageController { .json({ error: "before parameter is required" }); } + // Verify user is a participant + const chat = await this.chatService.findById(chatId); + if (!chat) { + return res.status(404).json({ error: "Chat not found" }); + } + if (!chat.participants.some((p) => p.id === userId)) { + return res + .status(403) + .json({ error: "Not a participant in this chat" }); + } + const result = await this.chatService.getChatMessagesBefore( chatId, beforeId, diff --git a/platforms/pictique/api/src/services/ChatService.ts b/platforms/pictique/api/src/services/ChatService.ts index b69d379bf..c1ea93fa4 100644 --- a/platforms/pictique/api/src/services/ChatService.ts +++ b/platforms/pictique/api/src/services/ChatService.ts @@ -3,7 +3,7 @@ import { Chat } from "../database/entities/Chat"; import { Message } from "../database/entities/Message"; import { User } from "../database/entities/User"; import { MessageReadStatus } from "../database/entities/MessageReadStatus"; -import { In, LessThan } from "typeorm"; +import { In, LessThan, Brackets } from "typeorm"; import { EventEmitter } from "events"; import { emitter } from "./event-emitter"; @@ -304,15 +304,29 @@ export class ChatService { throw new Error("Cursor message not found"); } - const messages = await this.messageRepository.find({ - where: { - chat: { id: chatId }, - createdAt: LessThan(cursorMessage.createdAt), - }, - relations: ["sender", "readStatuses", "readStatuses.user"], - order: { createdAt: "DESC" }, - take: limit + 1, - }); + const messages = await this.messageRepository + .createQueryBuilder("message") + .leftJoinAndSelect("message.sender", "sender") + .leftJoinAndSelect("message.readStatuses", "readStatuses") + .leftJoinAndSelect("readStatuses.user", "readStatusUser") + .where("message.chat.id = :chatId", { chatId }) + .andWhere( + new Brackets((qb) => { + qb.where("message.createdAt < :createdAt", { + createdAt: cursorMessage.createdAt, + }).orWhere( + "message.createdAt = :createdAt AND message.id < :beforeId", + { + createdAt: cursorMessage.createdAt, + beforeId, + } + ); + }) + ) + .orderBy("message.createdAt", "DESC") + .addOrderBy("message.id", "DESC") + .take(limit + 1) + .getMany(); const hasMore = messages.length > limit; const resultMessages = hasMore ? messages.slice(0, limit) : messages; diff --git a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte index 252c11d49..354dec528 100644 --- a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte @@ -4,7 +4,7 @@ import { ChatMessage, MessageInput } from '$lib/fragments'; import { apiClient, getAuthToken } from '$lib/utils/axios'; import moment from 'moment'; - import { onMount, tick } from 'svelte'; + import { onMount, onDestroy, tick } from 'svelte'; import { heading } from '../../../store'; import type { Chat } from '$lib/types'; @@ -13,11 +13,22 @@ let messages: Record[] = $state([]); let messageValue = $state(''); let messagesContainer: HTMLDivElement; + let eventSourceRef: EventSource | null = null; let historyLoaded = $state(false); let hasMore = $state(true); let loadingOlder = $state(false); let oldestMessageId = $state(null); let shouldScrollToBottom = $state(false); + let readTimeout: ReturnType | null = null; + + function markMessagesRead() { + if (readTimeout) clearTimeout(readTimeout); + readTimeout = setTimeout(() => { + apiClient.post(`/api/chats/${id}/messages/read`).catch((err: unknown) => { + console.error('Failed to mark messages as read:', err); + }); + }, 500); + } // Function to remove duplicate messages by ID function removeDuplicateMessages( @@ -66,7 +77,7 @@ } const sender = m.sender as Record; - const isOwn = sender.id !== userId; + const isOwn = sender.id === userId; return { id: m.id, @@ -119,6 +130,7 @@ PUBLIC_PICTIQUE_BASE_URL ).toString(); const eventSource = new EventSource(sseUrl); + eventSourceRef = eventSource; eventSource.onopen = () => { console.log('Successfully connected.'); @@ -154,7 +166,7 @@ } } - apiClient.post(`/api/chats/${id}/messages/read`); + markMessagesRead(); } catch (error) { console.error('Error parsing SSE message:', error); } @@ -234,6 +246,16 @@ await loadChatInfo(); watchEventStream(); }); + + onDestroy(() => { + if (eventSourceRef) { + eventSourceRef.close(); + eventSourceRef = null; + } + if (readTimeout) { + clearTimeout(readTimeout); + } + });
From 41f54c7233367e0dca85b36ba57bb3a6e6154a7c Mon Sep 17 00:00:00 2001 From: coodos Date: Mon, 16 Mar 2026 02:22:03 +0530 Subject: [PATCH 6/6] chore: revert isOwn --- .../client/src/routes/(protected)/messages/[id]/+page.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte index 354dec528..1c426f17c 100644 --- a/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte +++ b/platforms/pictique/client/src/routes/(protected)/messages/[id]/+page.svelte @@ -77,7 +77,7 @@ } const sender = m.sender as Record; - const isOwn = sender.id === userId; + const isOwn = sender.id !== userId; return { id: m.id,