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:
2026-04-14 16:35:38 +03:00
parent ef1fd4246e
commit e7ad9862ba
3 changed files with 77 additions and 22 deletions

View File

@@ -119,9 +119,11 @@ export function ChatWidget() {
const [activeRoom, setActiveRoom] = useState<ChatRoom | null>(null)
const [messages, setMessages] = useState<ChatMessage[]>([])
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<File | null>(null)
const [attachPreview, setAttachPreview] = useState<string | null>(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() {
</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 ? (
<div className="flex items-center justify-center h-32"><Loader2 size={20} className="animate-spin text-slate-400" /></div>
) : messages.length === 0 ? (

View File

@@ -333,8 +333,10 @@ export const api = {
chat: {
listRooms: (slug: string) =>
req<ChatRoom[]>('GET', `/api/hotels/${slug}/chat/rooms`),
getMessages: (slug: string, roomId: string, limit = 50) =>
req<ChatMessage[]>('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<ChatMessage[]>('GET', `/api/hotels/${slug}/chat/rooms/${roomId}/messages${qs}`)
},
sendMessage: (slug: string, roomId: string, text: string, attachmentUrl?: string) =>
req<ChatMessage>('POST', `/api/hotels/${slug}/chat/rooms/${roomId}/messages`, { text, ...(attachmentUrl ? { attachment_url: attachmentUrl } : {}) }),
uploadImage: async (file: File): Promise<{ url: string }> => {