- SSE: серверный heartbeat теперь именованное событие ping (25с) — клиент может отличить живое соединение от мёртвого; клиентский watchdog (45с без активности → принудительный reconnect), reconnect 1с вместо 3с, polling-фолбэк 5с вместо 10с - Скролл: на скрытой вкладке скролл не трогаем (раньше автоскролл «выбрасывал» пользователя вниз при возврате); возврат на вкладку — pin к низу если был у низа; при чтении истории плавающий индикатор «↓ N новых» (оба чата) - Персистентный кэш последних 100 сообщений чата в localStorage → initialData: мгновенный рендер после перезагрузки страницы без «Загрузка сообщений...», свежие данные в фоне; gcTime: Infinity - SW: reload на controllerchange только на скрытой вкладке (обновление незаметно), предыдущая версия кэша сохраняется для ленивых чанков старой страницы; кэш v15
331 lines
10 KiB
TypeScript
331 lines
10 KiB
TypeScript
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();
|
||
}
|