Files
grendervill f45cc21a9c feat(web): push-уведомления в браузере и раздел в настройках (7.16)
Клиент подписывает устройство на Web Push, а показывает уведомление service
worker — приложение при этом может быть и закрыто.

- `web/public/sw.js`: обработчики `push` (показывает уведомление, иначе браузер
  может отозвать подписку) и `notificationclick` (фокусирует окно и переводит
  его в комнату, иначе открывает новое). Путь из payload берётся только
  внутренний: `//host` и абсолютные адреса отбрасываются;
- `web/src/lib/webPush.ts`: поддержка API, запрос разрешения, подписка и
  отписка, преобразование ключей в base64url, слушатель перехода по клику
  (`push-navigate` → `history.pushState` без перезагрузки);
- `web/src/api/push.ts`: `GET /push/config`, `GET|POST|DELETE /push/subscriptions`;
- раздел «Push-уведомления» в настройках: тумблер «Включить на этом
  устройстве», состояние «включено», объяснение отказа браузера в разрешении и
  честная деградация — «браузер не поддерживает» или «на инстансе не настроено»;
- владелец инстанса управляет ключами, клиент только спрашивает публичный.

Тесты: `web/tests/webPush.test.ts` (поддержка API, ключи, подписка, отписка,
отказ в разрешении, переход по клику) и `web/tests/pushSettings.test.tsx`
(раздел настроек: деградация, включение, отключение, ошибка загрузки).
2026-09-26 16:15:24 +03:00

160 lines
5.4 KiB
JavaScript

/*
* Сервис-воркер glchat (AGENT.md, Фаза 5): офлайн-заглушка и кэш статики.
*
* Правила простые и безопасные:
* - навигация — сеть, при ошибке офлайн-страница (данные всегда свежие);
* - собранная статика `/assets/*` (хешированные имена) — из кэша;
* - API, шлюз и файлы никогда не кэшируются: это приватные данные.
*/
const VERSION = 'glchat-v1';
const OFFLINE_URL = '/offline.html';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(VERSION).then((cache) => cache.addAll([OFFLINE_URL, '/manifest.webmanifest'])),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(keys.filter((key) => key !== VERSION).map((key) => caches.delete(key))),
)
.then(() => self.clients.claim()),
);
});
/** Приватные запросы: их нельзя ни кэшировать, ни отдавать из кэша. */
function isPrivate(url) {
return (
url.pathname.startsWith('/api/') ||
url.pathname.startsWith('/files/') ||
url.pathname.startsWith('/gateway') ||
url.pathname.startsWith('/rtc')
);
}
self.addEventListener('fetch', (event) => {
const request = event.request;
if (request.method !== 'GET') {
return;
}
const url = new URL(request.url);
if (url.origin !== self.location.origin || isPrivate(url)) {
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() =>
caches.match(request).then((cached) => cached ?? caches.match(OFFLINE_URL)),
),
);
return;
}
if (url.pathname.startsWith('/assets/')) {
event.respondWith(
caches.match(request).then(
(cached) =>
cached ??
fetch(request).then((response) => {
if (response.ok) {
const copy = response.clone();
void caches.open(VERSION).then((cache) => cache.put(request, copy));
}
return response;
}),
),
);
return;
}
// Иконки, манифест и прочая статика: сеть, при недоступности — кэш.
event.respondWith(
fetch(request)
.then((response) => {
if (
response.ok &&
(url.pathname.startsWith('/icons/') || url.pathname === '/manifest.webmanifest')
) {
const copy = response.clone();
void caches.open(VERSION).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() => caches.match(request).then((cached) => cached ?? Response.error())),
);
});
/*
* Web Push (AGENT.md 7.16, Фаза 7).
*
* Текст уведомления готовит сервер (internal/push/rules.go) по тем же
* правилам, что нативные уведомления desktop-обёртки, а воркер только
* показывает его и открывает комнату по клику. Уведомление обязано
* показываться на каждый push: иначе браузер может отозвать подписку.
*/
/** sameOriginPath оставляет только внутренний путь: payload приходит извне. */
function sameOriginPath(value) {
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//')) {
return '/app';
}
return value;
}
self.addEventListener('push', (event) => {
if (!event.data) {
return;
}
let payload = null;
try {
payload = event.data.json();
} catch {
return;
}
if (payload === null || typeof payload !== 'object') {
return;
}
const title =
typeof payload.title === 'string' && payload.title !== '' ? payload.title : 'glchat';
const body = typeof payload.body === 'string' ? payload.body : '';
const options = {
body,
icon: '/icons/icon-192.png',
badge: '/icons/icon-192.png',
// Уведомления одной комнаты заменяют друг друга (Topic на сервере).
tag: typeof payload.tag === 'string' && payload.tag !== '' ? payload.tag : undefined,
data: {
url: sameOriginPath(payload.url),
channel_id: typeof payload.channel_id === 'string' ? payload.channel_id : '',
guild_id: typeof payload.guild_id === 'string' ? payload.guild_id : '',
},
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = sameOriginPath(event.notification.data && event.notification.data.url);
event.waitUntil(
(async () => {
const windows = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
for (const client of windows) {
if ('focus' in client) {
await client.focus();
// Открытое окно переходит в комнату без перезагрузки: клиент слушает
// это сообщение и делает history.pushState (web/src/lib/webPush.ts).
client.postMessage({ type: 'push-navigate', url: target });
return;
}
}
await self.clients.openWindow(target);
})(),
);
});