feat(chat): онлайн-доставка как в Telegram — watchdog SSE, тихий скролл, персистентный кэш сообщений, невидимое обновление SW
- SSE: серверный heartbeat теперь именованное событие ping (25с) — клиент может отличить живое соединение от мёртвого; клиентский watchdog (45с без активности → принудительный reconnect), reconnect 1с вместо 3с, polling-фолбэк 5с вместо 10с - Скролл: на скрытой вкладке скролл не трогаем (раньше автоскролл «выбрасывал» пользователя вниз при возврате); возврат на вкладку — pin к низу если был у низа; при чтении истории плавающий индикатор «↓ N новых» (оба чата) - Персистентный кэш последних 100 сообщений чата в localStorage → initialData: мгновенный рендер после перезагрузки страницы без «Загрузка сообщений...», свежие данные в фоне; gcTime: Infinity - SW: reload на controllerchange только на скрытой вкладке (обновление незаметно), предыдущая версия кэша сохраняется для ленивых чанков старой страницы; кэш v15
This commit is contained in:
@@ -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) => {
|
||||
)}
|
||||
</ScrollArea>
|
||||
|
||||
{/* Индикатор новых сообщений снизу (как в Telegram): виден, когда
|
||||
пользователь листает историю и вниз приходят сообщения */}
|
||||
{newBelowCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewBelowCount(0);
|
||||
scrollToBottomRef.current();
|
||||
}}
|
||||
className="absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-1.5 text-xs font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
data-testid="new-messages-pill"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
{newBelowCount === 1 ? '1 новое' : `${newBelowCount} новых`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Форма отправки сообщения */}
|
||||
<div className="relative px-3 pb-3">
|
||||
{/* Показать упоминаемых пользователей и ботов */}
|
||||
|
||||
@@ -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<ConvMessage>(`conv:${activeConvId}`);
|
||||
return cached ? { success: true, messages: cached.messages } : undefined;
|
||||
},
|
||||
initialDataUpdatedAt: () => {
|
||||
if (!activeConvId) return undefined;
|
||||
return getCachedMessages<ConvMessage>(`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<ConvMessage>({
|
||||
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,
|
||||
|
||||
@@ -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<T extends { id: number }>({
|
||||
queryKey,
|
||||
buildUrl,
|
||||
enabled,
|
||||
intervalMs = 10000,
|
||||
intervalMs = 5000,
|
||||
}: UseMessagePollingOptions) {
|
||||
// buildUrl — обычно inline-функция; держим в ref, чтобы не пересоздавать интервал на каждый рендер
|
||||
const buildUrlRef = useRef(buildUrl);
|
||||
|
||||
70
client/src/lib/messageCache.ts
Normal file
70
client/src/lib/messageCache.ts
Normal file
@@ -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<T>(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<T>(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;
|
||||
}
|
||||
}
|
||||
@@ -71,13 +71,27 @@ export async function registerServiceWorker(): Promise<ServiceWorkerRegistration
|
||||
});
|
||||
|
||||
// When the service worker controller changes (new version activated),
|
||||
// reload the page once so the user runs the latest code.
|
||||
// reload the page so the user runs the latest code. НО не на глазах у
|
||||
// пользователя: на видимой вкладке ждём, пока она уйдёт в фон, и перезагружаем
|
||||
// там — обновление происходит незаметно. Ранее reload был мгновенным и
|
||||
// выглядел как внезапный сброс чата посреди работы.
|
||||
let refreshing = false;
|
||||
const reloadOnce = () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,13 +7,48 @@ const listeners = new Map<string, Set<Listener>>();
|
||||
let eventSource: EventSource | null = null;
|
||||
let isConnecting = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | 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<typeof setInterval> | 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<TypingIndicator typingBots={typingBots} />
|
||||
</ScrollArea>
|
||||
|
||||
{/* Индикатор новых сообщений снизу (как в Telegram): виден, когда
|
||||
пользователь листает историю и вниз приходят сообщения */}
|
||||
{newBelowCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setNewBelowCount(0);
|
||||
scrollToBottom();
|
||||
}}
|
||||
className="absolute bottom-16 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 rounded-full bg-primary px-3.5 py-1.5 text-xs font-medium text-primary-foreground shadow-lg hover:opacity-90 transition-opacity"
|
||||
data-testid="new-messages-pill"
|
||||
>
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
{newBelowCount === 1 ? '1 новое' : `${newBelowCount} новых`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Input area */}
|
||||
<div className="shrink-0 border-t border-border p-3 space-y-2">
|
||||
{replyTo && (
|
||||
|
||||
Reference in New Issue
Block a user