- /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-бакет офиса)
812 lines
30 KiB
TypeScript
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 };
|
|
}
|