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`
(раздел настроек: деградация, включение, отключение, ошибка загрузки).
This commit is contained in:
2026-09-26 16:15:24 +03:00
parent a9b1073877
commit f45cc21a9c
9 changed files with 888 additions and 8 deletions
+68
View File
@@ -89,3 +89,71 @@ self.addEventListener('fetch', (event) => {
.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);
})(),
);
});