diff --git a/client/public/sw.js b/client/public/sw.js index 83c9e44..fcc454e 100644 --- a/client/public/sw.js +++ b/client/public/sw.js @@ -1,4 +1,4 @@ -const CACHE_NAME = 'iistwin-pwa-v14'; +const CACHE_NAME = 'iistwin-pwa-v15'; const STATIC_ASSETS = [ '/', '/index.html', @@ -328,9 +328,16 @@ self.addEventListener('activate', (event) => { console.log('[SW] Activating service worker...'); event.waitUntil( caches.keys().then((cacheNames) => { + // Старая страница может ещё работать под новым SW (reload отложен до ухода + // вкладки в фон). Если удалить её кэш сразу, ленивые чанки старой сборки + // дадут 404 — поэтому предыдущую версию кэша сохраняем. + const versionMatch = CACHE_NAME.match(/^(.*-v)(\d+)$/); + const prevCacheName = versionMatch + ? `${versionMatch[1]}${Number(versionMatch[2]) - 1}` + : null; return Promise.all( cacheNames - .filter((name) => name !== CACHE_NAME) + .filter((name) => name !== CACHE_NAME && name !== prevCacheName) .map((name) => { console.log('[SW] Deleting old cache:', name); return caches.delete(name); diff --git a/client/src/components/TaskChat.tsx b/client/src/components/TaskChat.tsx index 25c8cae..9630011 100644 --- a/client/src/components/TaskChat.tsx +++ b/client/src/components/TaskChat.tsx @@ -20,9 +20,10 @@ import { AutoResizeTextarea } from '@/components/ui/auto-resize-textarea'; import { ScrollArea } from '@/components/ui/scroll-area'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; -import { Send, User, AtSign, Reply, X, Check, MoreVertical, Bot, Link as LinkIcon, BarChart2, Paperclip, Loader2 } from 'lucide-react'; +import { Send, User, AtSign, Reply, X, Check, MoreVertical, Bot, Link as LinkIcon, BarChart2, Paperclip, Loader2, ChevronDown } from 'lucide-react'; import { cn } from '@/lib/utils'; import { isPWAInstalled, reportActiveChat, clearActiveChat, setActiveChatInSw, clearActiveChatInSw } from '@/lib/pwa'; +import { saveCachedMessages, getCachedMessages } from '@/lib/messageCache'; import { PendingAttachmentList } from '@/components/chat/ChatAttachments'; import type { ChatAttachment } from '@/components/chat/ChatAttachments'; import ReactMarkdown from 'react-markdown'; @@ -264,6 +265,15 @@ const TaskChat = ({ taskId }: TaskChatProps) => { queryKey: ['/api/tasks', taskId, 'messages'], enabled: taskId > 0, staleTime: 10000, // Данные считаются свежими 10 секунд - предотвращает лишние запросы + gcTime: Infinity, // Кэш не удалять при размонтировании — возврат в чат без «Загрузка...» + // Персистентный кэш: мгновенный рендер последних сообщений после перезагрузки + // страницы, свежие данные догружаются в фоне (initialDataUpdatedAt старше + // staleTime → фоновый refetch сразу после монтирования) + initialData: () => { + const cached = getCachedMessages(`task:${taskId}`); + return cached ? { success: true, messages: cached.messages } : undefined; + }, + initialDataUpdatedAt: () => getCachedMessages(`task:${taskId}`)?.savedAt, // ВАЖНО: Отключаем automatic refetch чтобы не удалять optimistic messages // SSE обеспечивает real-time обновления, polling fallback — ручной интервал выше refetchIntervalInBackground: false, // Не делать polling когда вкладка неактивна @@ -312,6 +322,15 @@ const TaskChat = ({ taskId }: TaskChatProps) => { const messages: TaskMessage[] = useMemo(() => (messagesData as any)?.messages || [], [messagesData]); + // Сохраняем последние сообщения в localStorage — для мгновенного рендера + // после перезагрузки страницы (см. initialData в useQuery выше) + useEffect(() => { + const msgs = (messagesData as any)?.messages; + if (taskId > 0 && Array.isArray(msgs) && msgs.length > 0) { + saveCachedMessages(`task:${taskId}`, msgs); + } + }, [messagesData, taskId]); + // Сообщения, дополненные полем replyTo для общего MessageBubble (из replyToMessage) const displayMessages: TaskChatMessage[] = useMemo( () => messages.map(m => ({ @@ -451,13 +470,19 @@ const TaskChat = ({ taskId }: TaskChatProps) => { // Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда const atBottomRef = useRef(true); + // Счётчик «новых снизу» (плавающий индикатор как в Telegram): показывается, + // когда пользователь листает историю и вниз приходят новые сообщения + const [newBelowCount, setNewBelowCount] = useState(0); // Отслеживаем, находится ли пользователь у низа списка useEffect(() => { const viewport = scrollAreaRef.current?.querySelector('[data-radix-scroll-area-viewport]'); if (!viewport) return; const onScroll = () => { - atBottomRef.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; + const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; + atBottomRef.current = atBottom; + // Доскроллил до низа — индикатор «новых» больше не нужен + if (atBottom) setNewBelowCount(0); }; viewport.addEventListener('scroll', onScroll, { passive: true }); return () => viewport.removeEventListener('scroll', onScroll); @@ -467,22 +492,56 @@ const TaskChat = ({ taskId }: TaskChatProps) => { useEffect(() => { if (!taskId) return; atBottomRef.current = true; + lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене задачи + setNewBelowCount(0); requestAnimationFrame(() => { - setTimeout(scrollToBottom, 150); + setTimeout(() => scrollToBottomRef.current(), 150); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [taskId]); // Автоскролл при новых сообщениях: только если пользователь у низа списка - // или последнее сообщение — его собственное (как в мессенджере) + // или последнее сообщение — его собственное (как в мессенджере). + // ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены, + // atBottomRef держит позицию на момент ухода, и любой скролл в фоне «выбрасывает» + // пользователя вниз при возврате. Вместо этого копим счётчик «новых снизу». + const lastSeenLengthRef = useRef(0); + const scrollToBottomRef = useRef(scrollToBottom); + scrollToBottomRef.current = scrollToBottom; useEffect(() => { if (isLoading || messages.length === 0) return; + const added = messages.length - lastSeenLengthRef.current; + lastSeenLengthRef.current = messages.length; + if (added <= 0) return; + const isHidden = typeof document !== 'undefined' && document.hidden; const last = messages[messages.length - 1]; const isMine = last?.author?.id === user?.id; - if (!atBottomRef.current && !isMine) return; + if (isHidden || (!atBottomRef.current && !isMine)) { + // Пользователь читает историю (или вкладка скрыта) — не двигаем вид, + // показываем индикатор новых сообщений + if (!atBottomRef.current || isHidden) setNewBelowCount(c => c + added); + return; + } + setNewBelowCount(0); // Небольшая задержка чтобы DOM успел обновиться - setTimeout(scrollToBottom, 100); + setTimeout(() => scrollToBottomRef.current(), 100); }, [messages.length, isLoading, user?.id]); + // Возврат на вкладку: если пользователь был у низа — мгновенно прижимаем + // к последнему сообщению; если листал историю — остаёмся на месте + // (индикатор «новых» уже показан счётчиком выше) + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState !== 'visible') return; + if (atBottomRef.current) { + scrollToBottomRef.current(); + setNewBelowCount(0); + } + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, []); + // Real-time polling для read receipts сообщений текущего пользователя (исключаем optimistic сообщения с отрицательными ID) const userMessageIds = user && messages.length > 0 ? messages @@ -1540,6 +1599,23 @@ const TaskChat = ({ taskId }: TaskChatProps) => { )} + {/* Индикатор новых сообщений снизу (как в Telegram): виден, когда + пользователь листает историю и вниз приходят сообщения */} + {newBelowCount > 0 && ( + + )} + {/* Форма отправки сообщения */}
{/* Показать упоминаемых пользователей и ботов */} diff --git a/client/src/components/chat/useChatController.ts b/client/src/components/chat/useChatController.ts index bf1b511..0d415d1 100644 --- a/client/src/components/chat/useChatController.ts +++ b/client/src/components/chat/useChatController.ts @@ -3,6 +3,7 @@ import { useQuery, useMutation } from '@tanstack/react-query'; import { useLocation, useParams } from 'wouter'; import { useVirtualizer } from '@tanstack/react-virtual'; import { apiRequest, queryClient } from '@/lib/queryClient'; +import { saveCachedMessages, getCachedMessages } from '@/lib/messageCache'; import { enqueueAttachment, getAllAttachments, @@ -192,6 +193,20 @@ export function useChatController() { queryKey: ['/api/messenger/conversations', activeConvId, 'messages'], enabled: !!activeConvId, refetchInterval: false, + staleTime: 10000, // Данные свежие 10 секунд; восстановленный из localStorage кэш старше → фоновый refetch + gcTime: Infinity, // Кэш не удалять при размонтировании — возврат в чат без «Загрузка...» + // Персистентный кэш: мгновенный рендер последних сообщений после перезагрузки + // страницы, свежие данные догружаются в фоне (initialDataUpdatedAt старше + // staleTime → фоновый refetch сразу после монтирования) + initialData: () => { + if (!activeConvId) return undefined; + const cached = getCachedMessages(`conv:${activeConvId}`); + return cached ? { success: true, messages: cached.messages } : undefined; + }, + initialDataUpdatedAt: () => { + if (!activeConvId) return undefined; + return getCachedMessages(`conv:${activeConvId}`)?.savedAt; + }, // Reconciliation: merge optimistic pending messages back after server refetches select: (data) => { if (!data) return data; @@ -214,6 +229,14 @@ export function useChatController() { }); const initialMessages = msgsData?.messages ?? []; + // Сохраняем последние сообщения в localStorage — для мгновенного рендера + // после перезагрузки страницы (см. initialData в useQuery выше) + useEffect(() => { + if (activeConvId && Array.isArray(msgsData?.messages) && msgsData.messages.length > 0) { + saveCachedMessages(`conv:${activeConvId}`, msgsData.messages); + } + }, [msgsData, activeConvId]); + // Reset pagination state on conversation switch useEffect(() => { setOlderMessages([]); @@ -261,6 +284,11 @@ export function useChatController() { // Пользователь у низа списка — автоскролл на новые сообщения разрешён только тогда const atBottomRef = useRef(true); + // Длина списка на момент последнего автоскролла — для подсчёта «новых снизу» + const lastSeenLengthRef = useRef(0); + // Счётчик «новых снизу» (плавающий индикатор как в Telegram): показывается, + // когда пользователь листает историю и вниз приходят новые сообщения + const [newBelowCount, setNewBelowCount] = useState(0); const scrollToBottom = useCallback(() => { if (chatListStateRef.current.shouldVirtualize) { @@ -276,7 +304,10 @@ export function useChatController() { const viewport = scrollRef.current?.querySelector('[data-radix-scroll-area-viewport]'); if (!viewport) return; const onScroll = () => { - atBottomRef.current = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; + const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 80; + atBottomRef.current = atBottom; + // Доскроллил до низа — индикатор «новых» больше не нужен + if (atBottom) setNewBelowCount(0); }; viewport.addEventListener('scroll', onScroll, { passive: true }); return () => viewport.removeEventListener('scroll', onScroll); @@ -662,7 +693,7 @@ export function useChatController() { }, []), }); - // Lightweight polling fallback: если SSE не доставил событие, раз в 10 секунд + // Lightweight polling fallback: если SSE не доставил событие, раз в 5 секунд // запрашиваем только новые сообщения после последнего известного id. useMessagePolling({ queryKey: ['/api/messenger/conversations', activeConvId, 'messages'], @@ -676,26 +707,58 @@ export function useChatController() { useEffect(() => { if (!activeConvId) return; atBottomRef.current = true; + lastSeenLengthRef.current = 0; // сброс счётчика «новых» при смене диалога + setNewBelowCount(0); requestAnimationFrame(() => { setTimeout(scrollToBottom, 120); }); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeConvId, scrollToBottom]); // Scroll to bottom when new messages arrive (tracked via initialMessages.length), // NOT when old messages are prepended via load-more. Скроллим только если // пользователь уже у низа списка или последнее сообщение — его собственное. + // ВАЖНО: пока вкладка скрыта, скролл НЕ трогаем — scroll-события заморожены, + // atBottomRef держит позицию на момент ухода, и любой скролл в фоне «выбрасывает» + // пользователя вниз при возврате. Вместо этого копим счётчик «новых снизу». useEffect(() => { if (msgsLoading || initialMessages.length === 0) return; + const added = initialMessages.length - lastSeenLengthRef.current; + lastSeenLengthRef.current = initialMessages.length; + if (added <= 0) return; + const isHidden = typeof document !== 'undefined' && document.hidden; const last = initialMessages[initialMessages.length - 1]; const isMine = last?.author?.id === user?.id; - if (!atBottomRef.current && !isMine) return; + if (isHidden || (!atBottomRef.current && !isMine)) { + // Пользователь читает историю (или вкладка скрыта) — не двигаем вид, + // показываем индикатор новых сообщений + if (!atBottomRef.current || isHidden) setNewBelowCount(c => c + added); + return; + } + setNewBelowCount(0); requestAnimationFrame(() => { setTimeout(scrollToBottom, 80); }); // initialMessages намеренно отслеживаем только по длине: prepend старых // сообщений через load-more не должен вызывать автоскролл + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialMessages.length, msgsLoading, scrollToBottom, user?.id]); + // Возврат на вкладку: если пользователь был у низа — мгновенно прижимаем + // к последнему сообщению; если листал историю — остаёмся на месте + // (индикатор «новых» уже показан счётчиком выше) + useEffect(() => { + const handleVisibility = () => { + if (document.visibilityState !== 'visible') return; + if (atBottomRef.current) { + scrollToBottom(); + setNewBelowCount(0); + } + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, [scrollToBottom]); + // ── Mark as read when switching conversations ───────────────────────────────── const markReadMutation = useMutation({ @@ -1160,6 +1223,9 @@ export function useChatController() { scrollRef, msgsLoading, messages, + newBelowCount, + setNewBelowCount, + scrollToBottom, hasMore, loadMoreMessages, isLoadingMore, diff --git a/client/src/components/chat/useMessagePolling.ts b/client/src/components/chat/useMessagePolling.ts index eade71b..4163b27 100644 --- a/client/src/components/chat/useMessagePolling.ts +++ b/client/src/components/chat/useMessagePolling.ts @@ -17,7 +17,7 @@ interface UseMessagePollingOptions { buildUrl: (afterId: number) => string; /** Опрос активен только при true (например, пока SSE отключён или чат открыт). */ enabled: boolean; - /** Интервал опроса в мс (по умолчанию 10 секунд). */ + /** Интервал опроса в мс (по умолчанию 5 секунд — только пока SSE отключён). */ intervalMs?: number; } @@ -25,7 +25,7 @@ export function useMessagePolling({ queryKey, buildUrl, enabled, - intervalMs = 10000, + intervalMs = 5000, }: UseMessagePollingOptions) { // buildUrl — обычно inline-функция; держим в ref, чтобы не пересоздавать интервал на каждый рендер const buildUrlRef = useRef(buildUrl); diff --git a/client/src/lib/messageCache.ts b/client/src/lib/messageCache.ts new file mode 100644 index 0000000..76ff95a --- /dev/null +++ b/client/src/lib/messageCache.ts @@ -0,0 +1,70 @@ +// ── Персистентный кэш последних сообщений чата ──────────────────────────────── +// После перезагрузки страницы (деплой, выгрузка вкладки Chrome Memory Saver, +// перезапуск PWA) кэш react-query пуст, и чат показывает «Загрузка сообщений...» +// плюс кадр «старые → новые». Чтобы этого не было, последние сообщения каждого +// чата сохраняются в localStorage и подкладываются в useQuery как initialData: +// чат рисуется мгновенно с последними известными сообщениями, а свежие данные +// догружаются в фоне (isLoading сразу false — без спиннера). + +const PREFIX = 'msgcache:'; +const MAX_MESSAGES = 100; +const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // неделя + +interface CachedMessages { + savedAt: number; + messages: unknown[]; +} + +// Удаляем протухшие ключи — иначе localService со временем забьётся старыми чатами +function pruneExpired() { + try { + const now = Date.now(); + const keysToRemove: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + if (!key || !key.startsWith(PREFIX)) continue; + try { + const raw = localStorage.getItem(key); + if (!raw) continue; + const data = JSON.parse(raw) as CachedMessages; + if (!data?.savedAt || now - data.savedAt > MAX_AGE_MS) { + keysToRemove.push(key); + } + } catch { + keysToRemove.push(key); + } + } + keysToRemove.forEach((key) => localStorage.removeItem(key)); + } catch { + // localStorage недоступен — пропускаем + } +} + +export function saveCachedMessages(key: string, messages: T[]) { + try { + const data: CachedMessages = { + savedAt: Date.now(), + messages: messages.slice(-MAX_MESSAGES), + }; + localStorage.setItem(PREFIX + key, JSON.stringify(data)); + pruneExpired(); + } catch { + // localStorage переполнен или недоступен — не критично, кэш best-effort + } +} + +export function getCachedMessages(key: string): { messages: T[]; savedAt: number } | null { + try { + const raw = localStorage.getItem(PREFIX + key); + if (!raw) return null; + const data = JSON.parse(raw) as CachedMessages; + if (!data || !Array.isArray(data.messages) || !data.savedAt) return null; + if (Date.now() - data.savedAt > MAX_AGE_MS) { + localStorage.removeItem(PREFIX + key); + return null; + } + return { messages: data.messages as T[], savedAt: data.savedAt }; + } catch { + return null; + } +} diff --git a/client/src/lib/pwa.ts b/client/src/lib/pwa.ts index f4bbfdb..fa87717 100644 --- a/client/src/lib/pwa.ts +++ b/client/src/lib/pwa.ts @@ -71,13 +71,27 @@ export async function registerServiceWorker(): Promise { + if (refreshing) return; + refreshing = true; + console.log('[PWA] Service worker controller changed, reloading...'); + window.location.reload(); + }; navigator.serviceWorker.addEventListener('controllerchange', () => { - if (!refreshing) { - refreshing = true; - console.log('[PWA] Service worker controller changed, reloading...'); - window.location.reload(); + if (document.hidden) { + reloadOnce(); + } else { + document.addEventListener('visibilitychange', function onHidden() { + if (document.hidden) { + document.removeEventListener('visibilitychange', onHidden); + reloadOnce(); + } + }); } }); diff --git a/client/src/lib/sseManager.ts b/client/src/lib/sseManager.ts index ff6b952..de72c86 100644 --- a/client/src/lib/sseManager.ts +++ b/client/src/lib/sseManager.ts @@ -7,13 +7,48 @@ const listeners = new Map>(); let eventSource: EventSource | null = null; let isConnecting = false; let reconnectTimer: ReturnType | null = null; -let reconnectDelay = 3000; +let reconnectDelay = 1000; let connectedListeners = new Set<(connected: boolean, connId: string | null) => void>(); let lastConnState = false; let lastConnId: string | null = null; let activeUserId: number | null = null; let lastEventId: string | null = null; +// ── Watchdog живости соединения ────────────────────────────────────────────── +// Сервер шлёт именованное событие `ping` каждые 25с. Если ни одного события/ping +// не было дольше WATCHDOG_TIMEOUT_MS — соединение считаем мёртвым (NAT/файрвол +// молча дропнул поток, EventSource об этом не узнает) и принудительно пересоздаём. +// Без этого клиент мог часами «полагать», что он онлайн, и получать сообщения +// только polling-фолбэком пачками раз в 10 секунд. +let lastActivityAt = Date.now(); +const WATCHDOG_INTERVAL_MS = 15000; +const WATCHDOG_TIMEOUT_MS = 45000; +let watchdogTimer: ReturnType | null = null; + +function touch() { + lastActivityAt = Date.now(); +} + +function forceReconnect(reason: string) { + console.log(`[SSE] ${reason} — forcing reconnect`); + if (eventSource) { eventSource.close(); eventSource = null; } + lastConnState = false; + lastConnId = null; + notifyConnState(); + scheduleReconnect(); +} + +function ensureWatchdog() { + if (watchdogTimer) return; + watchdogTimer = setInterval(() => { + if (!eventSource || activeUserId === null) return; + if (eventSource.readyState !== EventSource.OPEN) return; + if (Date.now() - lastActivityAt > WATCHDOG_TIMEOUT_MS) { + forceReconnect('watchdog: no activity'); + } + }, WATCHDOG_INTERVAL_MS); +} + const KNOWN_EVENTS = [ 'message_read', 'message_created', @@ -61,7 +96,7 @@ async function connect() { try { const tokenRes = await apiRequest('POST', '/api/events/token'); if (tokenRes.status === 429) { - reconnectDelay = Math.min(reconnectDelay * 2, 60000); + reconnectDelay = Math.min(reconnectDelay * 2, 30000); isConnecting = false; scheduleReconnect(); return; @@ -81,13 +116,20 @@ async function connect() { isConnecting = false; es.onopen = () => { - reconnectDelay = 3000; + reconnectDelay = 1000; + touch(); lastConnState = true; notifyConnState(); }; + // Именованный ping от сервера (heartbeat) — подтверждение живости соединения + es.addEventListener('ping', () => { + touch(); + }); + es.onmessage = (event) => { try { + touch(); if (event.lastEventId) { lastEventId = event.lastEventId; } @@ -102,6 +144,7 @@ async function connect() { for (const evName of KNOWN_EVENTS) { es.addEventListener(evName, (e: MessageEvent) => { try { + touch(); if (e.lastEventId) { lastEventId = e.lastEventId; } @@ -138,7 +181,7 @@ async function connect() { dispatchAuthFailure(); return; } - reconnectDelay = Math.min(reconnectDelay * 2, 60000); + reconnectDelay = Math.min(reconnectDelay * 2, 30000); scheduleReconnect(); } } @@ -158,6 +201,7 @@ export const sseManager = { if (userId === null) { disconnect(); } else { + ensureWatchdog(); connect(); } }, @@ -181,8 +225,18 @@ export const sseManager = { if (typeof document !== 'undefined') { document.addEventListener('visibilitychange', () => { - if (!document.hidden && activeUserId !== null && !eventSource && !isConnecting) { + if (document.hidden || activeUserId === null) return; + // Возврат на вкладку: сбрасываем backoff и переподключаемся сразу, не ждём + // таймер. Если соединение формально открыто, но давно без активности + // (вкладка была в фоне, watchdog троттлится) — пересоздаём принудительно. + reconnectDelay = 1000; + if (!eventSource && !isConnecting) { connect(); + return; + } + if (eventSource && eventSource.readyState === EventSource.OPEN && + Date.now() - lastActivityAt > WATCHDOG_TIMEOUT_MS) { + forceReconnect('visibility: stale connection'); } }); } diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 4376449..1fd12ce 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -9,7 +9,7 @@ import { getAvatarColor, getUserInitials } from '@/lib/avatar'; import { MessageSquare, Plus, Send, Reply, X, Check, Users, Link2, Paperclip, Loader2, - ChevronLeft, Bell, BellOff, BarChart2, + ChevronLeft, ChevronDown, Bell, BellOff, BarChart2, } from 'lucide-react'; import { PendingAttachmentList } from '@/components/chat/ChatAttachments'; import { cn } from '@/lib/utils'; @@ -54,6 +54,9 @@ export default function ChatPage() { scrollRef, msgsLoading, messages, + newBelowCount, + setNewBelowCount, + scrollToBottom, hasMore, loadMoreMessages, isLoadingMore, @@ -522,6 +525,23 @@ export default function ChatPage() { + {/* Индикатор новых сообщений снизу (как в Telegram): виден, когда + пользователь листает историю и вниз приходят сообщения */} + {newBelowCount > 0 && ( + + )} + {/* Input area */}
{replyTo && ( diff --git a/server/routes/shared.ts b/server/routes/shared.ts index 4cf65f3..6112408 100644 --- a/server/routes/shared.ts +++ b/server/routes/shared.ts @@ -84,7 +84,7 @@ export interface BufferedEvent extends Event { export class EventBus { // Индекс соединений по организации/пользователю — publishEvent не перебирает все соединения private index = new SseConnectionIndex(); - private heartbeatInterval = 30000; // 30 seconds + private heartbeatInterval = 25000; // 25 seconds — должен быть заметно меньше client watchdog (45s) private heartbeatTimer: NodeJS.Timeout | null = null; private eventIdCounter = 0; // Буферы последних событий для восстановления после reconnect @@ -112,7 +112,10 @@ export class EventBus { this.heartbeatTimer = setInterval(() => { this.index.forEach((connection) => { try { - connection.res.write(`: heartbeat\n\n`); + // Именованное событие ping — в отличие от SSE-комментария (": heartbeat"), + // оно доставляется клиенту как обычное событие и позволяет ему отличить + // живое соединение от мёртвого (watchdog в sseManager). + connection.res.write(`event: ping\ndata: {}\n\n`); const resAny = connection.res as any; if (typeof resAny.flush === 'function') { resAny.flush();