Files
iistwin/client/src/hooks/useOfflineSync.ts
Ильяс Султанов d0112657b8 fix(auth): живая сессия на устройстве — рефреш на холодном старте, grace-окно ротации, без логаута при временных ошибках
- /api/auth/me при 401 делает одну попытку refresh (раньше refresh-retry был
  отключён для всех /api/auth/* — холодный старт после 15 мин простоя = логаут)
- ротация: повтор старого refresh-токена в пределах RACE_TOLERANCE_MS (5 мин)
  выдаёт новую пару от successor вместо deny; cookie больше не стираются при
  stale-токенах (разлогинивались все вкладки устройства); reuse после окна —
  revoke family как раньше (тесты обновлены)
- refreshSession различает 401/403/400 (сессия мертва) vs 429/5xx/сеть
  (transient — нет logout, отложенный retry)
- единый рефреш: useAuth keep-alive и useOfflineSync переведены на refreshSession;
  межвкладочная дедупликация через BroadcastChannel('auth-refresh')
- refreshLimiter: keyGenerator buildRateLimitKey (per-user, не общий IP-бакет офиса)
2026-09-17 21:59:51 +03:00

812 lines
30 KiB
TypeScript

import { useState, useEffect, useCallback, useRef } from 'react';
import { getAll, remove, count, markAsConflict, clearConflictStatus, updateBody } from '@/lib/offlineQueue';
import { countAttachments } from '@/lib/attachmentQueue';
import type { OfflineQueueItem } from '@/lib/offlineQueue';
import {
getAllAttachments,
getAttachmentById,
removeAttachment,
updateAttachment,
markAttachmentError,
type AttachmentQueueItem,
} from '@/lib/attachmentQueue';
import {
performDeltaSync,
performInitialSync,
checkServerReachable,
} from '@/lib/syncEngine';
import { queryClient, refreshSession } from '@/lib/queryClient';
import { saveInbox, pruneStaleInboxTasks } from '@/lib/tasksCache';
import type { InboxTaskSnapshot } from '@/lib/tasksCache';
import { sseManager } from '@/lib/sseManager';
export type SyncStatus = 'idle' | 'syncing' | 'done' | 'error' | 'conflict';
interface UseOfflineSyncReturn {
isOnline: boolean;
isVisible: boolean;
pendingCount: number;
pendingChatCount: number;
pendingAttachmentCount: number;
pendingUrls: Set<string>;
pendingItems: OfflineQueueItem[];
pendingAttachments: AttachmentQueueItem[];
isPending: (url: string) => boolean;
getPendingBody: (url: string) => Record<string, unknown> | null;
status: SyncStatus;
conflicts: OfflineQueueItem[];
attachmentStatus: SyncStatus;
attachmentConflicts: AttachmentQueueItem[];
sync: () => Promise<void>;
resolveConflict: (id: string, resolution: 'force' | 'discard') => Promise<void>;
retryAttachment: (id: string) => Promise<void>;
discardAttachment: (id: string) => Promise<void>;
triggerInitialSync: (formIds?: number[]) => Promise<void>;
}
function isChatMessage(item: OfflineQueueItem): boolean {
return item.method === 'POST' && /\/api\/tasks\/[^/]+\/messages$/.test(item.url);
}
/**
* After a successful sync, refresh and persist the inbox snapshot for offline use.
* Uses the authenticated userId from app state (passed in).
* Authentication is handled automatically via HttpOnly cookies (credentials: 'include').
*/
async function refreshInboxSnapshot(userId: number): Promise<void> {
try {
const res = await fetch('/api/home/inbox?limit=200&offset=0', {
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok) return;
const body: unknown = await res.json();
const tasks = ((body as { tasks?: InboxTaskSnapshot[] })?.tasks ?? []) as InboxTaskSnapshot[];
const taskIds = tasks.map(t => t.id);
await saveInbox(userId, tasks, (body as { total?: number })?.total ?? tasks.length);
await pruneStaleInboxTasks(userId, taskIds);
} catch {
// Non-fatal
}
}
interface NavigatorBadge {
setAppBadge?: (count?: number) => Promise<void>;
clearAppBadge?: () => Promise<void>;
}
function updateAppBadge(n: number) {
const nav = navigator as unknown as NavigatorBadge;
if (n > 0) {
nav.setAppBadge?.(n).catch(() => {});
} else {
nav.clearAppBadge?.().catch(() => {});
}
}
async function replayRequest(item: OfflineQueueItem): Promise<Response> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (item.enqueuedAt) {
headers['X-Offline-Enqueued-At'] = String(item.enqueuedAt);
}
return fetch(item.url, {
method: item.method,
headers,
body: item.body ?? undefined,
credentials: 'include',
});
}
function isSafeToDrop(item: OfflineQueueItem): boolean {
return item.method === 'POST' && /\/api\/messages\/\d+\/read$/.test(item.url);
}
// Module-level lock so only one sync runs at a time across all hook instances.
let _syncLock: Promise<void> | null = null;
/**
* On 409 Conflict: check if the conflict is "real" (another user changed the
* same fields) or "phantom" (only updatedAt changed, our fields are still at
* the old server value). Uses the stored snapshot to decide: if the server's
* current value equals the snapshot (nobody else changed it), we can safely
* retry without a conflict UI. If the server differs from the snapshot,
* another user/bot modified the field → real conflict.
*
* If no snapshot is present we fall back to the legacy behaviour (compare
* server value with the value the user wanted to write).
*/
async function tryAutoResolve409(item: OfflineQueueItem): Promise<'resolved' | 'conflict'> {
let userBody: Record<string, unknown> = {};
try {
if (item.body) userBody = JSON.parse(item.body) as Record<string, unknown>;
} catch { return 'conflict'; }
if (!('updatedAt' in userBody)) return 'conflict';
try {
const url = item.url.replace(/\?.*$/, '');
const serverRes = await fetch(url, { credentials: 'include' });
if (!serverRes.ok) return 'conflict';
const serverRaw = await serverRes.json() as unknown;
let serverData: Record<string, unknown> | null = null;
if (serverRaw && typeof serverRaw === 'object' && !Array.isArray(serverRaw)) {
const obj = serverRaw as Record<string, unknown>;
if ('success' in obj) {
for (const key of Object.keys(obj)) {
if (key === 'success') continue;
const val = obj[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
serverData = val as Record<string, unknown>;
break;
}
}
} else {
serverData = obj;
}
}
if (!serverData) return 'conflict';
const userChanges = Object.fromEntries(
Object.entries(userBody).filter(([k]) => k !== 'updatedAt' && k !== 'id')
);
const snapshot = item.snapshot;
const hasSnapshot = snapshot && typeof snapshot === 'object';
let hasRealConflict = false;
for (const [field, userValue] of Object.entries(userChanges)) {
const serverValue = serverData[field];
const serverStr = serverValue === null || serverValue === undefined ? '' : String(serverValue);
if (hasSnapshot && field in snapshot) {
const snapshotValue = snapshot[field];
const snapshotStr = snapshotValue === null || snapshotValue === undefined ? '' : String(snapshotValue);
// Real conflict only if the server value changed since we started editing
if (serverStr !== '' && serverStr !== snapshotStr) {
hasRealConflict = true;
break;
}
} else {
// Legacy fallback when no snapshot is available
const userStr = userValue === null || userValue === undefined ? '' : String(userValue);
if (serverStr !== '' && serverStr !== userStr) {
hasRealConflict = true;
break;
}
}
}
if (hasRealConflict) return 'conflict';
const bodyWithoutUpdatedAt = { ...userBody };
delete bodyWithoutUpdatedAt.updatedAt;
const retryHeaders: Record<string, string> = { 'Content-Type': 'application/json' };
if (item.enqueuedAt) retryHeaders['X-Offline-Enqueued-At'] = String(item.enqueuedAt);
const retryRes = await fetch(item.url, {
method: item.method,
headers: retryHeaders,
body: JSON.stringify(bodyWithoutUpdatedAt),
credentials: 'include',
});
return retryRes.ok ? 'resolved' : 'conflict';
} catch {
return 'conflict';
}
}
async function tryUploadFile(
file: { name: string; type: string; blob: Blob },
allowRefresh: boolean,
): Promise<{ url: string; name: string; size: number; mimeType?: string }> {
const formData = new FormData();
formData.append('file', file.blob, file.name);
const res = await fetch('/api/upload', {
method: 'POST',
body: formData,
credentials: 'include',
});
if ((res.status === 401 || res.status === 403) && allowRefresh) {
// Try a single session refresh via the shared refreshSession (singleton +
// cross-tab dedup); if it fails the user must re-authenticate.
const refreshResult = await refreshSession();
if (refreshResult.ok) {
return tryUploadFile(file, false);
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
// Код статуса в сообщении обязателен: вызывающий код отличает постоянные
// ошибки (400/401/403 → не ретраить) от временных по паттерну `\b400\b` и т.п.
throw new Error(`${res.status}: ${err.error || `Ошибка загрузки: ${file.name}`}`);
}
const data = await res.json();
return {
url: data.url,
name: data.name,
size: data.size,
mimeType: file.type || undefined,
};
}
async function uploadAttachmentFile(file: { name: string; type: string; blob: Blob }): Promise<{ url: string; name: string; size: number; mimeType?: string }> {
return tryUploadFile(file, true);
}
async function trySendAttachmentMessage(
item: AttachmentQueueItem,
attachments: { url: string; name: string; size: number; mimeType?: string }[],
allowRefresh: boolean,
): Promise<void> {
const url = item.type === 'messenger'
? `/api/messenger/conversations/${item.targetId}/messages`
: `/api/tasks/${item.targetId}/messages`;
const payload: Record<string, unknown> = {
message: item.message || '',
attachments,
};
if (item.type === 'messenger' && (item.replyToId || item.replyToMessageId)) {
payload.replyToId = item.replyToId || item.replyToMessageId;
}
if (item.type === 'task' && (item.replyToMessageId || item.replyToId)) {
payload.replyToMessageId = item.replyToMessageId || item.replyToId;
}
if (item.mentionedUserIds && item.mentionedUserIds.length > 0) {
payload.mentionedUserIds = item.mentionedUserIds;
}
if (item.mentionedBotIds && item.mentionedBotIds.length > 0) {
payload.mentionedBotIds = item.mentionedBotIds;
}
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
credentials: 'include',
});
if ((res.status === 401 || res.status === 403) && allowRefresh) {
const refreshResult = await refreshSession();
if (refreshResult.ok) {
return trySendAttachmentMessage(item, attachments, false);
}
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(`${res.status}: ${err.error || 'Ошибка отправки сообщения'}`);
}
}
async function sendAttachmentMessage(item: AttachmentQueueItem, attachments: { url: string; name: string; size: number; mimeType?: string }[]): Promise<void> {
return trySendAttachmentMessage(item, attachments, true);
}
export function useOfflineSync(userId?: number): UseOfflineSyncReturn {
const [isOnline, setIsOnline] = useState<boolean>(navigator.onLine);
const [isVisible, setIsVisible] = useState<boolean>(() => document.visibilityState === 'visible');
const [pendingCount, setPendingCount] = useState<number>(0);
const [pendingChatCount, setPendingChatCount] = useState<number>(0);
const [pendingAttachmentCount, setPendingAttachmentCount] = useState<number>(0);
const [pendingUrls, setPendingUrls] = useState<Set<string>>(new Set());
const [pendingItems, setPendingItems] = useState<OfflineQueueItem[]>([]);
const [pendingAttachments, setPendingAttachments] = useState<AttachmentQueueItem[]>([]);
const [status, setStatus] = useState<SyncStatus>('idle');
const [attachmentStatus, setAttachmentStatus] = useState<SyncStatus>('idle');
const [conflicts, setConflicts] = useState<OfflineQueueItem[]>([]);
const [attachmentConflicts, setAttachmentConflicts] = useState<AttachmentQueueItem[]>([]);
const userIdRef = useRef(userId);
useEffect(() => { userIdRef.current = userId; }, [userId]);
const refreshCount = useCallback(async () => {
try {
const items = await getAll();
const attachments = await getAllAttachments();
setPendingCount(items.length + attachments.length);
setPendingChatCount(items.filter(isChatMessage).length);
setPendingAttachmentCount(attachments.length);
setPendingUrls(new Set(items.map(item => item.url)));
setPendingItems(items);
setPendingAttachments(attachments);
setConflicts(items.filter(i => i.conflictStatus === 'conflict'));
setAttachmentConflicts(attachments.filter(a => a.status === 'error'));
updateAppBadge(items.length + attachments.length);
} catch {
// IndexedDB not available (e.g. private mode on some browsers)
}
}, []);
// On mount: read pending count, then auto-sync if already online with pending items
useEffect(() => {
refreshCount().then(async () => {
try {
const [n, attachmentN] = await Promise.all([count(), countAttachments()]);
if ((n > 0 || attachmentN > 0) && navigator.onLine) {
sync();
}
} catch {
// IndexedDB not available
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const sync = useCallback(async () => {
if (_syncLock) {
await _syncLock;
return;
}
_syncLock = (async () => {
setStatus('syncing');
try {
// 1. Pull server-side changes (delta sync). Runs only when online.
// Pending mutations are replayed below; the legacy /api/sync/push endpoint
// is a no-op and is kept only for backward compatibility.
if (navigator.onLine) {
await performDeltaSync().catch((err) => {
console.warn('[offlineSync] Delta sync failed:', err);
});
}
const [items, attachments] = await Promise.all([getAll(), getAllAttachments()]);
if (items.length === 0 && attachments.length === 0) {
setStatus('idle');
setAttachmentStatus('idle');
setPendingCount(0);
setPendingChatCount(0);
setPendingAttachmentCount(0);
setPendingUrls(new Set());
setPendingItems([]);
setPendingAttachments([]);
setConflicts([]);
setAttachmentConflicts([]);
updateAppBadge(0);
return;
}
// Collapse sequential edits: merge multiple PUT/PATCH to the same URL (last-write-wins)
const conflictItems = items.filter(i => i.conflictStatus === 'conflict');
const nonConflictItems = items.filter(i => !i.conflictStatus);
type CollapseGroup = { winner: OfflineQueueItem; losers: OfflineQueueItem[] };
const collapsedMap = new Map<string, CollapseGroup>();
const collapsedOrder: string[] = [];
for (const item of nonConflictItems) {
if (item.method !== 'PUT' && item.method !== 'PATCH') {
const key = item.id;
collapsedMap.set(key, { winner: item, losers: [] });
collapsedOrder.push(key);
continue;
}
const groupKey = `${item.method}:${item.url}`;
if (collapsedMap.has(groupKey)) {
const group = collapsedMap.get(groupKey)!;
let prevBody: Record<string, unknown> = {};
let newBody: Record<string, unknown> = {};
try { prevBody = JSON.parse(group.winner.body ?? '{}'); } catch { /* ignore */ }
try { newBody = JSON.parse(item.body ?? '{}'); } catch { /* ignore */ }
group.losers.push(group.winner);
group.winner = { ...item, body: JSON.stringify({ ...prevBody, ...newBody }) };
} else {
collapsedMap.set(groupKey, { winner: item, losers: [] });
collapsedOrder.push(groupKey);
}
}
// Remove loser items from IDB and persist merged body for winners
for (const [, group] of collapsedMap) {
for (const loser of group.losers) {
await remove(loser.id);
}
if (group.losers.length > 0 && group.winner.body) {
await updateBody(group.winner.id, group.winner.body);
}
}
const itemsToReplay = [
...collapsedOrder.map(k => collapsedMap.get(k)!.winner),
...conflictItems,
];
let hadTransientError = false;
const newConflicts: OfflineQueueItem[] = [];
for (const item of itemsToReplay) {
// Items already marked as conflicted need manual resolution — skip during auto-sync
if (item.conflictStatus === 'conflict') {
newConflicts.push(item);
continue;
}
try {
const res = await replayRequest(item);
if (res.ok) {
// 2xx: success — remove from queue
await remove(item.id);
// Notify listeners (e.g. TaskChat) that this queue item is gone
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
setPendingUrls(prev => {
const next = new Set(prev);
next.delete(item.url);
return next;
});
} else if (res.status === 429 || res.status >= 500) {
// Transient server-side error (rate limited or 5xx) — stop and retry later
hadTransientError = true;
break;
} else if (res.status === 409) {
// Optimistic concurrency conflict: check if our fields actually conflict
// with the server state, or if only updatedAt changed (phantom conflict).
const resolution = await tryAutoResolve409(item);
if (resolution === 'resolved') {
await remove(item.id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
setPendingUrls(prev => {
const next = new Set(prev);
next.delete(item.url);
return next;
});
} else {
await markAsConflict(item.id);
newConflicts.push({ ...item, conflictStatus: 'conflict' });
}
} else {
// Other 4xx: the request failed for a semantic reason (validation, etc.)
// Safe (non-critical) requests are silently dropped; everything else
// is shown in the conflict UI so the user can decide.
if (isSafeToDrop(item)) {
await remove(item.id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
setPendingUrls(prev => {
const next = new Set(prev);
next.delete(item.url);
return next;
});
} else {
await markAsConflict(item.id);
newConflicts.push({ ...item, conflictStatus: 'conflict' });
}
}
} catch {
// Network error during replay — stop and retry later
hadTransientError = true;
break;
}
}
// ── Replay attachment queue (messages with files) ──────────────────────────
let attachmentTransientError = false;
if (!hadTransientError) {
const attachments = await getAllAttachments();
for (const item of attachments) {
if (item.status === 'error') continue;
try {
const uploaded = [];
for (const file of item.files) {
uploaded.push(await uploadAttachmentFile(file));
}
await sendAttachmentMessage(item, uploaded);
await removeAttachment(item.id);
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
} catch (err: any) {
const msg = err?.message || '';
// Auth errors / validation errors — mark as error, don't retry blindly
if (/\b401\b/.test(msg) || /\b403\b/.test(msg) || /\b400\b/.test(msg)) {
await markAttachmentError(item.id, msg);
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
continue;
}
// Transient errors — stop and retry later
attachmentTransientError = true;
break;
}
}
}
const remainingItems = await getAll();
const remainingAttachments = await getAllAttachments();
setPendingCount(remainingItems.length + remainingAttachments.length);
setPendingChatCount(remainingItems.filter(isChatMessage).length);
setPendingAttachmentCount(remainingAttachments.length);
setPendingUrls(new Set(remainingItems.map(i => i.url)));
setPendingItems(remainingItems);
setPendingAttachments(remainingAttachments);
setConflicts(newConflicts);
updateAppBadge(remainingItems.length + remainingAttachments.length);
const finalHadError = hadTransientError || attachmentTransientError;
if (newConflicts.length > 0) {
setStatus('conflict');
} else if (finalHadError) {
setStatus('error');
} else {
setStatus('done');
setAttachmentStatus('done');
// Invalidate all queries so UI refreshes with server data
await queryClient.invalidateQueries();
// Refresh inbox snapshot for offline cache using authenticated userId from app state
const uid = userIdRef.current;
if (uid) {
refreshInboxSnapshot(uid).catch(() => {});
}
setTimeout(() => {
setStatus('idle');
setAttachmentStatus('idle');
}, 3000);
}
} catch {
setStatus('error');
setAttachmentStatus('error');
} finally {
_syncLock = null;
}
})();
await _syncLock;
}, []);
const resolveConflict = useCallback(async (id: string, resolution: 'force' | 'discard') => {
try {
const items = await getAll();
const item = items.find(i => i.id === id);
if (!item) return;
setAttachmentStatus('idle');
if (resolution === 'discard') {
// User explicitly chooses to throw away their pending change
await remove(id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
} else {
// Force: replay the request and only remove from queue on 2xx success.
// On any failure keep the item in the conflict queue so the user can retry.
await clearConflictStatus(id);
let success = false;
try {
const res = await replayRequest(item);
success = res.ok;
} catch {
// Network error — force replay failed
}
if (success) {
await remove(id);
window.dispatchEvent(new CustomEvent('offline-queue-updated'));
} else {
// Re-mark as conflict so it stays visible for another attempt
await markAsConflict(id);
}
}
const remaining = await getAll();
const remainingAttachments = await getAllAttachments();
setPendingCount(remaining.length + remainingAttachments.length);
setPendingChatCount(remaining.filter(isChatMessage).length);
setPendingAttachmentCount(remainingAttachments.length);
setPendingUrls(new Set(remaining.map(i => i.url)));
setPendingItems(remaining);
setPendingAttachments(remainingAttachments);
const remainingConflicts = remaining.filter(i => i.conflictStatus === 'conflict');
setConflicts(remainingConflicts);
setAttachmentConflicts(remainingAttachments.filter(a => a.status === 'error'));
updateAppBadge(remaining.length + remainingAttachments.length);
if (remainingConflicts.length === 0) {
await queryClient.invalidateQueries();
const allDone = remaining.length === 0 && remainingAttachments.length === 0;
if (allDone) {
setStatus('done');
setAttachmentStatus('done');
setTimeout(() => {
setStatus('idle');
setAttachmentStatus('idle');
}, 3000);
} else {
setStatus('idle');
setAttachmentStatus('idle');
}
}
} catch {
// IndexedDB error
}
}, []);
const discardAttachment = useCallback(async (id: string) => {
try {
await removeAttachment(id);
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
const remaining = await getAll();
const remainingAttachments = await getAllAttachments();
setPendingCount(remaining.length + remainingAttachments.length);
setPendingAttachmentCount(remainingAttachments.length);
setPendingItems(remaining);
setPendingAttachments(remainingAttachments);
setAttachmentConflicts(remainingAttachments.filter(a => a.status === 'error'));
updateAppBadge(remaining.length + remainingAttachments.length);
} catch {
// IndexedDB error
}
}, []);
const retryAttachment = useCallback(async (id: string) => {
try {
const item = await getAttachmentById(id);
if (!item) return;
const updated: AttachmentQueueItem = { ...item, status: 'pending', error: null };
await updateAttachment(updated);
setAttachmentStatus('syncing');
window.dispatchEvent(new CustomEvent('attachment-queue-updated'));
sync();
} catch {
// IndexedDB error
}
}, [sync]);
// Track page visibility so we don't run connectivity checks or show offline UI
// while the PWA/tab is in the background.
useEffect(() => {
const handleVisibilityChange = () => {
setIsVisible(document.visibilityState === 'visible');
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
}, []);
// Listen for online/offline events
useEffect(() => {
const handleOnline = () => {
// When hidden, only update the flag; actual sync runs when the user returns
setIsOnline(true);
if (!isVisible) return;
(navigator.serviceWorker?.ready as Promise<any>)
.then(r => r.sync?.register('offline-sync'))
.catch(() => {});
sync();
};
const handleOffline = () => {
// Don't mark offline while hidden to avoid false alerts on throttled timers/network
if (!isVisible) return;
setIsOnline(false);
setStatus('idle');
};
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, [sync, isVisible]);
// Heartbeat: navigator.onLine can lie (captive portals, flaky Wi-Fi).
// Periodically hit the server to verify real connectivity.
useEffect(() => {
let mounted = true;
const HEARTBEAT_INTERVAL_MS = 120_000; // 2 minutes — enough to detect real connectivity changes without constant polling
const tick = async () => {
if (!mounted || !isVisible) return;
const reachable = await checkServerReachable();
if (reachable && !isOnline) {
setIsOnline(true);
sync();
} else if (!reachable && isOnline) {
setIsOnline(false);
setStatus('idle');
}
};
const interval = setInterval(tick, HEARTBEAT_INTERVAL_MS);
return () => {
mounted = false;
clearInterval(interval);
};
}, [isOnline, isVisible, sync]);
// When the tab/PWA becomes visible, verify connectivity immediately
// so the user sees the actual state right away.
const wasVisibleRef = useRef(false);
useEffect(() => {
if (!isVisible) {
wasVisibleRef.current = false;
return;
}
if (wasVisibleRef.current) return;
wasVisibleRef.current = true;
checkServerReachable().then(reachable => {
if (reachable && !isOnline) {
setIsOnline(true);
sync();
} else if (!reachable && isOnline) {
setIsOnline(false);
setStatus('idle');
}
});
}, [isVisible, isOnline, sync]);
const triggerInitialSync = useCallback(async (formIds?: number[]) => {
setStatus('syncing');
try {
await performInitialSync(formIds);
setStatus('done');
setTimeout(() => setStatus('idle'), 3000);
} catch (err) {
console.error('[offlineSync] Initial sync failed:', err);
setStatus('error');
}
}, []);
// Listen for Background Sync trigger relayed by pwa.ts as a DOM event.
useEffect(() => {
const handleSwSync = () => sync();
window.addEventListener('offline-sw-sync', handleSwSync);
return () => window.removeEventListener('offline-sw-sync', handleSwSync);
}, [sync]);
// SSE: when a task is updated on the server, refresh the offline cache
// so users with offline sync enabled see the latest data immediately.
useEffect(() => {
const unsubscribe = sseManager.on('task_updated', () => {
if (navigator.onLine) {
sync().catch(() => {});
}
});
return unsubscribe;
}, [sync]);
// Custom DOM event: fired by apiRequest when a new item is enqueued.
useEffect(() => {
const handleQueueUpdated = () => refreshCount();
window.addEventListener('offline-queue-updated', handleQueueUpdated);
return () => window.removeEventListener('offline-queue-updated', handleQueueUpdated);
}, [refreshCount]);
// Attachment queue events
useEffect(() => {
const handleAttachmentQueueUpdated = () => {
refreshCount();
if (navigator.onLine) {
sync();
}
};
window.addEventListener('attachment-queue-updated', handleAttachmentQueueUpdated);
return () => window.removeEventListener('attachment-queue-updated', handleAttachmentQueueUpdated);
}, [refreshCount, sync]);
useEffect(() => {
const handleAttachmentSwSync = () => sync();
window.addEventListener('attachment-sw-sync', handleAttachmentSwSync);
return () => window.removeEventListener('attachment-sw-sync', handleAttachmentSwSync);
}, [sync]);
const isPending = useCallback((url: string) => pendingUrls.has(url), [pendingUrls]);
const getPendingBody = useCallback((url: string): Record<string, unknown> | null => {
const matching = pendingItems.filter(i => i.url === url && !i.conflictStatus);
if (matching.length === 0) return null;
const latest = matching.reduce((a, b) => a.enqueuedAt > b.enqueuedAt ? a : b);
if (!latest?.body) return null;
try { return JSON.parse(latest.body); } catch { return null; }
}, [pendingItems]);
return { isOnline, isVisible, pendingCount, pendingChatCount, pendingAttachmentCount, pendingUrls, pendingItems, pendingAttachments, isPending, getPendingBody, status, conflicts, attachmentStatus, attachmentConflicts, sync, resolveConflict, retryAttachment, discardAttachment, triggerInitialSync };
}