feat: chat pagination — infinite scroll / load older messages
- Backend: cursor-based pagination via ?before=<ISO timestamp> - Frontend: auto-load when scrolling to top (<60px), spinner + manual 'Загрузить ещё' button - Poll merge: preserves older history when new messages arrive from polling - hasMoreMsgs flag: shown only when exactly 50 msgs returned (more may exist) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,7 +111,7 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// ── GET messages ──────────────────────────────────────────────────────────
|
// ── GET messages ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
fastify.get<RoomParam & { Querystring: { limit?: string } }>(
|
fastify.get<RoomParam & { Querystring: { limit?: string; before?: string } }>(
|
||||||
'/api/hotels/:slug/chat/rooms/:roomId/messages',
|
'/api/hotels/:slug/chat/rooms/:roomId/messages',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
@@ -122,8 +122,13 @@ const chatRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const limit = Math.min(Number(request.query.limit ?? 50), 100)
|
const limit = Math.min(Number(request.query.limit ?? 50), 100)
|
||||||
|
const before = request.query.before // ISO timestamp cursor
|
||||||
const userId = request.user.sub
|
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(
|
const { rows } = await db.query(
|
||||||
`SELECT m.id, m.room_id, m.sender_id, m.text, m.created_at,
|
`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,
|
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
|
) AS reactions
|
||||||
FROM chat_messages m
|
FROM chat_messages m
|
||||||
LEFT JOIN users u ON u.id = m.sender_id
|
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
|
ORDER BY m.created_at DESC
|
||||||
LIMIT $4`,
|
LIMIT $4`,
|
||||||
[roomId, hotelId, userId, limit],
|
params,
|
||||||
)
|
)
|
||||||
|
|
||||||
await db.query(
|
// Only mark read on initial load (no before cursor)
|
||||||
`INSERT INTO chat_read_status (room_id, user_id, last_read) VALUES ($1, $2, NOW())
|
if (!before) {
|
||||||
ON CONFLICT (room_id, user_id) DO UPDATE SET last_read = NOW()`,
|
await db.query(
|
||||||
[roomId, userId],
|
`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()
|
return rows.reverse()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,9 +119,11 @@ export function ChatWidget() {
|
|||||||
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
|
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [loadingRooms, setLoadingRooms] = useState(false)
|
const [loadingRooms, setLoadingRooms] = useState(false)
|
||||||
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
const [loadingMsgs, setLoadingMsgs] = useState(false)
|
||||||
const [sending, setSending] = useState(false)
|
const [loadingOlder, setLoadingOlder] = useState(false)
|
||||||
|
const [hasMoreMsgs, setHasMoreMsgs] = useState(false)
|
||||||
|
const [sending, setSending] = useState(false)
|
||||||
const [attachment, setAttachment] = useState<File | null>(null)
|
const [attachment, setAttachment] = useState<File | null>(null)
|
||||||
const [attachPreview, setAttachPreview] = useState<string | null>(null)
|
const [attachPreview, setAttachPreview] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -251,16 +253,22 @@ export function ChatWidget() {
|
|||||||
if (view !== 'messages' || !activeRoom) return
|
if (view !== 'messages' || !activeRoom) return
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const [msgs, typing] = await Promise.all([
|
const [fresh, typing] = await Promise.all([
|
||||||
api.chat.getMessages(slug, activeRoom.id),
|
api.chat.getMessages(slug, activeRoom.id),
|
||||||
api.chat.getTyping(slug, activeRoom.id),
|
api.chat.getTyping(slug, activeRoom.id),
|
||||||
])
|
])
|
||||||
if (settings.soundEnabled && msgs.length > prevMsgCountRef.current && prevMsgCountRef.current > 0) {
|
setMessages(prev => {
|
||||||
const last = msgs[msgs.length - 1]
|
// Merge: keep older history + update/append fresh messages
|
||||||
if (last.senderId !== user?.id) playNotifSound()
|
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))
|
||||||
prevMsgCountRef.current = msgs.length
|
const merged = [...older, ...fresh]
|
||||||
setMessages(msgs)
|
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)
|
setTypingNames(typing)
|
||||||
} catch { /**/ }
|
} catch { /**/ }
|
||||||
}, 2000)
|
}, 2000)
|
||||||
@@ -346,10 +354,11 @@ export function ChatWidget() {
|
|||||||
const openRoom = async (room: ChatRoom) => {
|
const openRoom = async (room: ChatRoom) => {
|
||||||
setActiveRoom(room); setView('messages'); setLoadingMsgs(true)
|
setActiveRoom(room); setView('messages'); setLoadingMsgs(true)
|
||||||
setRoomSearch(''); setRoomSearchOpen(false)
|
setRoomSearch(''); setRoomSearchOpen(false)
|
||||||
prevMsgCountRef.current = 0; isTypingRef.current = false
|
prevMsgCountRef.current = 0; isTypingRef.current = false; setHasMoreMsgs(false)
|
||||||
try {
|
try {
|
||||||
const msgs = await api.chat.getMessages(slug, room.id)
|
const msgs = await api.chat.getMessages(slug, room.id)
|
||||||
prevMsgCountRef.current = msgs.length
|
prevMsgCountRef.current = msgs.length
|
||||||
|
setHasMoreMsgs(msgs.length === 50)
|
||||||
setMessages(msgs)
|
setMessages(msgs)
|
||||||
await api.chat.markRead(slug, room.id)
|
await api.chat.markRead(slug, room.id)
|
||||||
setRooms(prev => prev.map(r => r.id === room.id ? { ...r, unreadCount: 0 } : r))
|
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 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 = () => {
|
const goBack = () => {
|
||||||
if (view === 'messages') {
|
if (view === 'messages') {
|
||||||
sendTyping(false)
|
sendTyping(false)
|
||||||
setRoomSearch(''); setRoomSearchOpen(false)
|
setRoomSearch(''); setRoomSearchOpen(false)
|
||||||
|
setHasMoreMsgs(false)
|
||||||
setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([])
|
setView('rooms'); setActiveRoom(null); setEditingMsgId(null); setTypingNames([])
|
||||||
}
|
}
|
||||||
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
|
else if (view === 'search') { setView('rooms'); setSearchQuery('') }
|
||||||
@@ -657,7 +686,23 @@ export function ChatWidget() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
<div ref={messagesContainerRef} className="flex-1 overflow-y-auto px-3 py-3 space-y-2"
|
||||||
|
onScroll={e => { if ((e.target as HTMLDivElement).scrollTop < 60 && hasMoreMsgs && !loadingOlder) void loadOlderMessages() }}>
|
||||||
|
{/* Load more older messages */}
|
||||||
|
{hasMoreMsgs && !loadingOlder && (
|
||||||
|
<div className="flex justify-center pt-1 pb-2">
|
||||||
|
<button onClick={() => void loadOlderMessages()}
|
||||||
|
className="text-xs text-brand-600 hover:text-brand-700 hover:underline transition-colors">
|
||||||
|
Загрузить ещё
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{loadingOlder && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<Loader2 size={14} className="animate-spin text-slate-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{loadingMsgs ? (
|
{loadingMsgs ? (
|
||||||
<div className="flex items-center justify-center h-32"><Loader2 size={20} className="animate-spin text-slate-400" /></div>
|
<div className="flex items-center justify-center h-32"><Loader2 size={20} className="animate-spin text-slate-400" /></div>
|
||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
|
|||||||
@@ -333,8 +333,10 @@ export const api = {
|
|||||||
chat: {
|
chat: {
|
||||||
listRooms: (slug: string) =>
|
listRooms: (slug: string) =>
|
||||||
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
|
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
|
||||||
getMessages: (slug: string, roomId: string, limit = 50) =>
|
getMessages: (slug: string, roomId: string, limit = 50, before?: string) => {
|
||||||
req<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages?limit=${limit}`),
|
const qs = before ? `?limit=${limit}&before=${encodeURIComponent(before)}` : `?limit=${limit}`
|
||||||
|
return req<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages${qs}`)
|
||||||
|
},
|
||||||
sendMessage: (slug: string, roomId: string, text: string, attachmentUrl?: string) =>
|
sendMessage: (slug: string, roomId: string, text: string, attachmentUrl?: string) =>
|
||||||
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text, ...(attachmentUrl ? { attachment_url: attachmentUrl } : {}) }),
|
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text, ...(attachmentUrl ? { attachment_url: attachmentUrl } : {}) }),
|
||||||
uploadImage: async (file: File): Promise<{ url: string }> => {
|
uploadImage: async (file: File): Promise<{ url: string }> => {
|
||||||
|
|||||||
Reference in New Issue
Block a user