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:
@@ -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);
|
||||
})(),
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user