diff --git a/backend/src/routes/chat.ts b/backend/src/routes/chat.ts index 3971f71..d1d7fcf 100644 --- a/backend/src/routes/chat.ts +++ b/backend/src/routes/chat.ts @@ -111,7 +111,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { // ── GET messages ────────────────────────────────────────────────────────── - fastify.get( + fastify.get( '/api/hotels/:slug/chat/rooms/:roomId/messages', { onRequest: [fastify.authenticate] }, async (request, reply) => { @@ -122,8 +122,13 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' }) const limit = Math.min(Number(request.query.limit ?? 50), 100) + const before = request.query.before // ISO timestamp cursor const userId = request.user.sub + const params: unknown[] = [roomId, hotelId, userId, limit] + const beforeClause = before ? `AND m.created_at < $5` : '' + if (before) params.push(before) + const { rows } = await db.query( `SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at, m.is_system, m.system_name, m.attachment_url, m.edited_at, m.deleted_at, @@ -142,17 +147,20 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => { ) AS reactions FROM chat_messages m LEFT JOIN users u ON u.id = m.sender_id - WHERE m.room_id = $1 AND m.hotel_id = $2 + WHERE m.room_id = $1 AND m.hotel_id = $2 ${beforeClause} ORDER BY m.created_at DESC LIMIT $4`, - [roomId, hotelId, userId, limit], + params, ) - await db.query( - `INSERT INTO chat_read_status (room_id, user_id, last_read) VALUES ($1, $2, NOW()) - ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`, - [roomId, userId], - ) + // Only mark read on initial load (no before cursor) + if (!before) { + await db.query( + `INSERT INTO chat_read_status (room_id, user_id, last_read) VALUES ($1, $2, NOW()) + ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`, + [roomId, userId], + ) + } return rows.reverse() }, ) diff --git a/src/components/chat/ChatWidget.tsx b/src/components/chat/ChatWidget.tsx index a98ec49..9fa3d4f 100644 --- a/src/components/chat/ChatWidget.tsx +++ b/src/components/chat/ChatWidget.tsx @@ -119,9 +119,11 @@ export function ChatWidget() { const [activeRoom, setActiveRoom] = useState(null) const [messages, setMessages] = useState([]) const [text, setText] = useState('') - const [loadingRooms, setLoadingRooms] = useState(false) - const [loadingMsgs, setLoadingMsgs] = useState(false) - const [sending, setSending] = useState(false) + const [loadingRooms, setLoadingRooms] = useState(false) + const [loadingMsgs, setLoadingMsgs] = useState(false) + const [loadingOlder, setLoadingOlder] = useState(false) + const [hasMoreMsgs, setHasMoreMsgs] = useState(false) + const [sending, setSending] = useState(false) const [attachment, setAttachment] = useState(null) const [attachPreview, setAttachPreview] = useState(null) @@ -251,16 +253,22 @@ export function ChatWidget() { if (view !== 'messages' || !activeRoom) return const interval = setInterval(async () => { try { - const [msgs, typing] = await Promise.all([ + const [fresh, typing] = await Promise.all([ api.chat.getMessages(slug, activeRoom.id), api.chat.getTyping(slug, activeRoom.id), ]) - if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) { - const last = msgs[msgs.length - 1] - if (last.senderId !== user?.id) playNotifSound() - } - prevMsgCountRef.current = msgs.length - setMessages(msgs) + setMessages(prev => { + // Merge: keep older history + update/append fresh messages + const freshIds = new Set(fresh.map(m => m.id)) + const older = prev.filter(m => !freshIds.has(m.id) && new Date(m.createdAt) < new Date(fresh[0]?.createdAt ?? 0)) + const merged = [...older, ...fresh] + if (settings.soundEnabled && merged.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) { + const last = merged[merged.length - 1] + if (last.senderId !== user?.id) playNotifSound() + } + prevMsgCountRef.current = merged.length + return merged + }) setTypingNames(typing) } catch { /**/ } }, 2000) @@ -346,10 +354,11 @@ export function ChatWidget() { const openRoom = async (room: ChatRoom) => { setActiveRoom(room); setView('messages'); setLoadingMsgs(true) setRoomSearch(''); setRoomSearchOpen(false) - prevMsgCountRef.current = 0; isTypingRef.current = false + prevMsgCountRef.current = 0; isTypingRef.current = false; setHasMoreMsgs(false) try { const msgs = await api.chat.getMessages(slug, room.id) prevMsgCountRef.current = msgs.length + setHasMoreMsgs(msgs.length === 50) setMessages(msgs) await api.chat.markRead(slug, room.id) setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r)) @@ -449,10 +458,30 @@ export function ChatWidget() { const next = { ...settings, ...patch }; setSettings(next); saveSettings(next) } + const loadOlderMessages = async () => { + if (!activeRoom || loadingOlder || !hasMoreMsgs || messages.length === 0) return + const oldest = messages[0].createdAt + setLoadingOlder(true) + try { + const older = await api.chat.getMessages(slug, activeRoom.id, 50, oldest) + if (older.length === 0) { setHasMoreMsgs(false); return } + setHasMoreMsgs(older.length === 50) + // Preserve scroll position after prepending + const el = messagesContainerRef.current + const prevHeight = el?.scrollHeight ?? 0 + setMessages(prev => [...older, ...prev]) + requestAnimationFrame(() => { + if (el) el.scrollTop = el.scrollHeight - prevHeight + }) + } catch { /**/ } + finally { setLoadingOlder(false) } + } + const goBack = () => { if (view === 'messages') { sendTyping(false) setRoomSearch(''); setRoomSearchOpen(false) + setHasMoreMsgs(false) setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([]) } else if (view === 'search') { setView('rooms'); setSearchQuery('') } @@ -657,7 +686,23 @@ export function ChatWidget() { )} -
+
{ if ((e.target as HTMLDivElement).scrollTop < 60 && hasMoreMsgs && !loadingOlder) void loadOlderMessages() }}> + {/* Load more older messages */} + {hasMoreMsgs && !loadingOlder && ( +
+ +
+ )} + {loadingOlder && ( +
+ +
+ )} + {loadingMsgs ? (
) : messages.length === 0 ? ( diff --git a/src/lib/api.ts b/src/lib/api.ts index 94669f8..cdc6497 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -333,8 +333,10 @@ export const api = { chat: { listRooms: (slug: string) => req('GET', `/api/hotels/${slug}/chat/rooms`), - getMessages: (slug: string, roomId: string, limit = 50) => - req('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`), + getMessages: (slug: string, roomId: string, limit = 50, before?: string) => { + const qs = before ? `?limit=${limit}&before=${encodeURIComponent(before)}` : `?limit=${limit}` + return req('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages${qs}`) + }, sendMessage: (slug: string, roomId: string, text: string, attachmentUrl?: string) => req('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text, ...(attachmentUrl ? { attachment_url: attachmentUrl } : {}) }), uploadImage: async (file: File): Promise<{ url: string }> => {