Files
iistwin/client/src/lib/pwa.ts
Ильяс Султанов 34838a302d 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
2026-09-21 20:29:13 +03:00

331 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { apiRequest, fetchWithTimeout } from './queryClient';
let deferredPrompt: BeforeInstallPromptEvent | null = null;
let swRegistration: ServiceWorkerRegistration | null = null;
interface BeforeInstallPromptEvent extends Event {
prompt: () => Promise<void>;
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
}
export function isPWAInstalled(): boolean {
return window.matchMedia('(display-mode: standalone)').matches ||
(window.navigator as any).standalone === true;
}
export function canInstallPWA(): boolean {
return deferredPrompt !== null;
}
export async function installPWA(): Promise<boolean> {
if (!deferredPrompt) {
console.log('[PWA] No install prompt available');
return false;
}
try {
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
console.log('[PWA] Install prompt outcome:', outcome);
deferredPrompt = null;
return outcome === 'accepted';
} catch (error) {
console.error('[PWA] Install error:', error);
return false;
}
}
export async function registerServiceWorker(): Promise<ServiceWorkerRegistration | null> {
if (!('serviceWorker' in navigator)) {
console.log('[PWA] Service Worker not supported');
return null;
}
try {
const registration = await navigator.serviceWorker.register('/sw.js', {
scope: '/',
// Ensure the browser never uses a cached copy of the SW script;
// always fetch the latest sw.js from the server.
updateViaCache: 'none',
});
// Force an update check immediately on app load so deployments are picked
// up as soon as the user refreshes the page.
registration.update().catch(() => {});
console.log('[PWA] Service Worker registered:', registration.scope);
swRegistration = registration;
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
if (newWorker) {
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed' && navigator.serviceWorker.controller) {
console.log('[PWA] New version available, skipping waiting...');
// Force the waiting service worker to activate so users don't stay
// stuck on the old version after a deployment.
newWorker.postMessage({ type: 'SKIP_WAITING' });
}
});
}
});
// When the service worker controller changes (new version activated),
// 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 (document.hidden) {
reloadOnce();
} else {
document.addEventListener('visibilitychange', function onHidden() {
if (document.hidden) {
document.removeEventListener('visibilitychange', onHidden);
reloadOnce();
}
});
}
});
// Centralized SW message handler: relay TRIGGER_SYNC as a DOM event so
// any mounted hook (useOfflineSync) can pick it up without coupling directly
// to the SW message channel. Also handle push subscription rotation.
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data?.type === 'TRIGGER_SYNC') {
console.log('[PWA] SW requested offline-queue sync');
window.dispatchEvent(new CustomEvent('offline-sw-sync'));
}
if (event.data?.type === 'TRIGGER_ATTACHMENT_SYNC') {
console.log('[PWA] SW requested attachment-queue sync');
window.dispatchEvent(new CustomEvent('attachment-sw-sync'));
}
if (event.data?.type === 'PUSH_SUBSCRIPTION_CHANGE') {
console.log('[PWA] SW reported push subscription change');
// Re-fetch VAPID key and resubscribe; the server will store the new endpoint.
initWebPush().catch((err) => console.error('[PWA] Resubscribe failed:', err));
}
});
return registration;
} catch (error) {
console.error('[PWA] Service Worker registration failed:', error);
return null;
}
}
export async function requestPushPermission(): Promise<NotificationPermission> {
if (!('Notification' in window)) {
console.log('[PWA] Notifications not supported');
return 'denied';
}
if (Notification.permission === 'granted') {
return 'granted';
}
if (Notification.permission === 'denied') {
return 'denied';
}
const permission = await Notification.requestPermission();
console.log('[PWA] Notification permission:', permission);
return permission;
}
export async function subscribeToPush(vapidPublicKey: string): Promise<PushSubscription | null> {
if (!swRegistration) {
console.log('[PWA] No service worker registration');
return null;
}
try {
let subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
console.log('[PWA] Found existing push subscription');
} else {
subscription = await swRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
});
console.log('[PWA] New push subscription created');
}
// Always register subscription with server (ensures it's in the database)
await apiRequest('POST', '/api/devices/register-web-push', {
subscription: subscription.toJSON()
});
console.log('[PWA] Subscription registered with server');
return subscription;
} catch (error) {
console.error('[PWA] Push subscription failed:', error);
return null;
}
}
export async function unsubscribeFromPush(): Promise<boolean> {
if (!swRegistration) {
return false;
}
try {
const subscription = await swRegistration.pushManager.getSubscription();
if (subscription) {
await subscription.unsubscribe();
await apiRequest('POST', '/api/devices/unregister-web-push', {
endpoint: subscription.endpoint
});
console.log('[PWA] Unsubscribed from push');
return true;
}
return false;
} catch (error) {
console.error('[PWA] Unsubscribe failed:', error);
return false;
}
}
export async function getPushSubscription(): Promise<PushSubscription | null> {
if (!swRegistration) {
return null;
}
try {
return await swRegistration.pushManager.getSubscription();
} catch {
return null;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
function withTimeout<T>(promise: Promise<T>, ms: number, context: string): Promise<T> {
return Promise.race([
promise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error(`Timeout: ${context}`)), ms)
),
]);
}
export async function initWebPush(): Promise<void> {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.log('[PWA] Push notifications not supported');
return;
}
try {
const registration = await withTimeout(
navigator.serviceWorker.ready,
5000,
'service worker ready'
);
swRegistration = registration;
console.log('[PWA] Service worker ready for push');
} catch (error) {
console.error('[PWA] Service worker not ready:', error);
return;
}
const permission = await requestPushPermission();
if (permission !== 'granted') {
console.log('[PWA] Push permission not granted');
return;
}
try {
const response = await fetchWithTimeout('/api/web-push/vapid-key', {}, 5000);
const data = await response.json();
if (data.vapidPublicKey) {
await withTimeout(subscribeToPush(data.vapidPublicKey), 10000, 'subscribe to push');
}
} catch (error) {
console.error('[PWA] Failed to init web push:', error);
}
}
// ── Active chat reporting (used to suppress push for open chats) ──────────────
export async function reportActiveChat(
type: 'messenger' | 'task',
id: number,
visible: boolean
): Promise<void> {
try {
await apiRequest('POST', '/api/devices/active-chat', { type, id, visible });
} catch (error) {
console.error('[ActiveChat] Failed to report active chat:', error);
}
}
export async function clearActiveChat(): Promise<void> {
try {
await apiRequest('DELETE', '/api/devices/active-chat');
} catch (error) {
console.error('[ActiveChat] Failed to clear active chat:', error);
}
}
export function setActiveChatInSw(
type: 'messenger' | 'task',
id: number,
visible: boolean
): void {
if (!('serviceWorker' in navigator)) return;
const controller = navigator.serviceWorker.controller;
if (!controller) return;
controller.postMessage({ type: 'ACTIVE_CHAT', payload: { type, id, visible } });
}
export function clearActiveChatInSw(): void {
if (!('serviceWorker' in navigator)) return;
const controller = navigator.serviceWorker.controller;
if (!controller) return;
controller.postMessage({ type: 'CLEAR_ACTIVE_CHAT' });
}
export function initPWA(): void {
window.addEventListener('beforeinstallprompt', (e) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
console.log('[PWA] Install prompt ready');
window.dispatchEvent(new CustomEvent('pwa-install-available'));
});
window.addEventListener('appinstalled', () => {
console.log('[PWA] App installed');
deferredPrompt = null;
window.dispatchEvent(new CustomEvent('pwa-installed'));
});
registerServiceWorker();
}