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:
@@ -0,0 +1,54 @@
|
||||
import { request } from './client';
|
||||
|
||||
/**
|
||||
* Web Push (AGENT.md 7.16, Фаза 7): параметры push и подписки устройств.
|
||||
*
|
||||
* Публичный VAPID-ключ нужен браузеру как `applicationServerKey`; приватный
|
||||
* остаётся на сервере. Подписка — это устройство: одно и то же устройство
|
||||
* может переподписаться, тогда запись обновляется.
|
||||
*/
|
||||
|
||||
export interface PushConfig {
|
||||
enabled: boolean;
|
||||
public_key?: string;
|
||||
}
|
||||
|
||||
export interface PushSubscriptionInfo {
|
||||
id: string;
|
||||
endpoint: string;
|
||||
user_agent?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PushSubscriptionList {
|
||||
subscriptions: PushSubscriptionInfo[];
|
||||
/** Сколько устройств можно подписать (лимит инстанса). */
|
||||
limit: number;
|
||||
}
|
||||
|
||||
export const pushConfigQueryKey = ['push-config'] as const;
|
||||
export const pushSubscriptionsQueryKey = ['push-subscriptions'] as const;
|
||||
|
||||
export function fetchPushConfig(signal?: AbortSignal): Promise<PushConfig> {
|
||||
return request<PushConfig>('/push/config', signal === undefined ? {} : { signal });
|
||||
}
|
||||
|
||||
export function listPushSubscriptions(signal?: AbortSignal): Promise<PushSubscriptionList> {
|
||||
return request<PushSubscriptionList>(
|
||||
'/push/subscriptions',
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
}
|
||||
|
||||
export function subscribePush(body: {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
}): Promise<{ ok: boolean }> {
|
||||
return request<{ ok: boolean }>('/push/subscriptions', { method: 'POST', body });
|
||||
}
|
||||
|
||||
/** Отписка: без эндпоинта сервер убирает все устройства пользователя. */
|
||||
export function unsubscribePush(endpoint?: string): Promise<{ ok: boolean }> {
|
||||
const query = endpoint === undefined ? '' : `?endpoint=${encodeURIComponent(endpoint)}`;
|
||||
return request<{ ok: boolean }>(`/push/subscriptions${query}`, { method: 'DELETE' });
|
||||
}
|
||||
@@ -124,7 +124,11 @@
|
||||
"event_required": "Choose an event for a palette sound."
|
||||
},
|
||||
"voice.not_connected": "Join a voice channel first.",
|
||||
"user.banned": "This account is banned on the instance. Contact the administrator."
|
||||
"user.banned": "This account is banned on the instance. Contact the administrator.",
|
||||
"push.disabled": "Push notifications are disabled on this instance.",
|
||||
"push.invalid_endpoint": "The browser sent an invalid push service endpoint.",
|
||||
"push.invalid_keys": "The browser sent invalid subscription keys.",
|
||||
"push.too_many_subscriptions": "Too many devices with push notifications: disable some of them."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Light theme",
|
||||
@@ -1203,7 +1207,17 @@
|
||||
"desktop": "Desktop notifications",
|
||||
"desktopHint": "Show system notifications when the window is inactive or the channel is not open. Native in the desktop app, permission-based in the browser.",
|
||||
"desktopSettings": "Desktop app settings",
|
||||
"unsupported": "This browser does not show system notifications: open the client in the desktop app."
|
||||
"unsupported": "This browser does not show system notifications: open the client in the desktop app.",
|
||||
"push": "Push notifications",
|
||||
"pushHint": "Notifications are delivered to this device even when the browser is closed. Works in Chrome, Firefox, Safari and Edge on desktop and mobile.",
|
||||
"pushEnable": "Enable on this device",
|
||||
"pushDisable": "Disable on this device",
|
||||
"pushActive": "Push notifications are enabled on this device.",
|
||||
"pushDenied": "The browser blocked notifications. Allow them in the site settings and try again.",
|
||||
"pushUnsupported": "This browser does not support push notifications.",
|
||||
"pushUnavailable": "Push notifications are not configured on this instance.",
|
||||
"pushError": "Could not enable push notifications. Please try again.",
|
||||
"pushWorking": "Setting up…"
|
||||
},
|
||||
"voice": {
|
||||
"title": "Audio & video",
|
||||
|
||||
@@ -124,7 +124,11 @@
|
||||
"event_required": "Для звука палитры выберите событие."
|
||||
},
|
||||
"voice.not_connected": "Сначала войдите в голосовую комнату.",
|
||||
"user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору."
|
||||
"user.banned": "Аккаунт забанен на этом инстансе. Обратитесь к администратору.",
|
||||
"push.disabled": "Push-уведомления на инстансе выключены.",
|
||||
"push.invalid_endpoint": "Браузер прислал некорректный адрес push-сервиса.",
|
||||
"push.invalid_keys": "Браузер прислал некорректные ключи подписки.",
|
||||
"push.too_many_subscriptions": "Слишком много устройств с push-уведомлениями: отключите лишние."
|
||||
},
|
||||
"theme": {
|
||||
"switchToLight": "Светлая тема",
|
||||
@@ -1203,7 +1207,17 @@
|
||||
"desktop": "Уведомления рабочего стола",
|
||||
"desktopHint": "Показывать системные уведомления, когда окно неактивно или комната не открыта. В desktop-приложении уведомления нативные, в браузере — при разрешённых уведомлениях.",
|
||||
"desktopSettings": "Настройки desktop-приложения",
|
||||
"unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении."
|
||||
"unsupported": "Браузер не показывает системные уведомления: откройте клиент в desktop-приложении.",
|
||||
"push": "Push-уведомления",
|
||||
"pushHint": "Уведомления приходят на это устройство, даже когда браузер закрыт. Работает в Chrome, Firefox, Safari, Edge на компьютере и на телефоне.",
|
||||
"pushEnable": "Включить на этом устройстве",
|
||||
"pushDisable": "Выключить на этом устройстве",
|
||||
"pushActive": "Push-уведомления включены на этом устройстве.",
|
||||
"pushDenied": "Браузер запретил уведомления. Разрешите их в настройках сайта и повторите.",
|
||||
"pushUnsupported": "Этот браузер не поддерживает push-уведомления.",
|
||||
"pushUnavailable": "На этом инстансе push-уведомления не настроены.",
|
||||
"pushError": "Не удалось включить push-уведомления. Попробуйте ещё раз.",
|
||||
"pushWorking": "Настраиваем…"
|
||||
},
|
||||
"voice": {
|
||||
"title": "Аудио-видео",
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Web Push в браузере (AGENT.md 7.16, Фаза 7).
|
||||
*
|
||||
* Модуль отвечает только за подписку устройства: поддержку API, запрос
|
||||
* разрешения, регистрацию service worker и преобразование подписки в тело
|
||||
* запроса к серверу. Решение «показывать уведомление или нет» принимает
|
||||
* сервер (internal/push/rules.go), а показывает его service worker
|
||||
* (web/public/sw.js) — приложение для этого может быть и закрыто.
|
||||
*/
|
||||
|
||||
/** Состояние push на этом устройстве. */
|
||||
export type PushAvailability =
|
||||
| 'unsupported' // браузер не умеет push или service worker
|
||||
| 'unavailable' // на инстансе не настроены VAPID-ключи
|
||||
| 'denied' // пользователь запретил уведомления в браузере
|
||||
| 'off' // поддерживается, но устройство не подписано
|
||||
| 'on'; // устройство подписано
|
||||
|
||||
/** Поддерживает ли браузер всё необходимое. */
|
||||
export function pushSupported(): boolean {
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window;
|
||||
}
|
||||
|
||||
/** Текущее разрешение браузера на уведомления. */
|
||||
export function notificationPermission(): NotificationPermission | null {
|
||||
if (!pushSupported()) {
|
||||
return null;
|
||||
}
|
||||
return Notification.permission;
|
||||
}
|
||||
|
||||
/**
|
||||
* urlBase64ToUint8Array переводит VAPID-ключ сервера в `applicationServerKey`.
|
||||
* Ключ приходит в base64url без выравнивания — добавляем padding сами.
|
||||
*/
|
||||
export function urlBase64ToUint8Array(base64: string): Uint8Array<ArrayBuffer> {
|
||||
const normalized = base64.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=');
|
||||
const binary = window.atob(padded);
|
||||
const bytes = new Uint8Array(new ArrayBuffer(binary.length));
|
||||
for (let index = 0; index < binary.length; index += 1) {
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** Тело запроса подписки: то, что ждёт сервер (endpoint + ключи). */
|
||||
export interface PushSubscriptionBody {
|
||||
endpoint: string;
|
||||
keys: { p256dh: string; auth: string };
|
||||
}
|
||||
|
||||
/** arrayBufferToBase64URL кодирует ключи подписки в base64url без padding. */
|
||||
export function arrayBufferToBase64URL(buffer: ArrayBuffer | null): string {
|
||||
if (buffer === null) {
|
||||
return '';
|
||||
}
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (const byte of bytes) {
|
||||
binary += String.fromCharCode(byte);
|
||||
}
|
||||
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/** Приводит PushSubscription к телу запроса; null, если ключей нет. */
|
||||
export function subscriptionBody(subscription: PushSubscription): PushSubscriptionBody | null {
|
||||
const p256dh = arrayBufferToBase64URL(subscription.getKey('p256dh'));
|
||||
const auth = arrayBufferToBase64URL(subscription.getKey('auth'));
|
||||
if (p256dh === '' || auth === '' || subscription.endpoint === '') {
|
||||
return null;
|
||||
}
|
||||
return { endpoint: subscription.endpoint, keys: { p256dh, auth } };
|
||||
}
|
||||
|
||||
/**
|
||||
* ensureServiceWorker дожидается регистрации воркера. Обычно он уже
|
||||
* зарегистрирован при запуске приложения (web/src/lib/serviceWorker.ts), но в
|
||||
* dev-сборке регистрация не выполняется — для push регистрируем явно.
|
||||
*/
|
||||
async function ensureServiceWorker(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!pushSupported()) {
|
||||
return null;
|
||||
}
|
||||
const existing = await navigator.serviceWorker.getRegistration('/');
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
try {
|
||||
return await navigator.serviceWorker.register('/sw.js', { scope: '/' });
|
||||
} catch (error) {
|
||||
console.warn('service worker registration failed', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Текущая подписка устройства или null. */
|
||||
export async function currentPushSubscription(): Promise<PushSubscription | null> {
|
||||
const registration = await ensureServiceWorker();
|
||||
if (registration === null) {
|
||||
return null;
|
||||
}
|
||||
return registration.pushManager.getSubscription();
|
||||
}
|
||||
|
||||
/**
|
||||
* Подписывает устройство. Возвращает тело запроса для сервера или null, если
|
||||
* подписка не удалась (нет поддержки, отказ пользователя, ошибка браузера).
|
||||
*/
|
||||
export async function subscribeToPush(publicKey: string): Promise<PushSubscriptionBody | null> {
|
||||
const registration = await ensureServiceWorker();
|
||||
if (registration === null || publicKey === '') {
|
||||
return null;
|
||||
}
|
||||
// Разрешение запрашиваем до subscribe: браузеры требуют пользовательский жест.
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
return null;
|
||||
}
|
||||
const existing = await registration.pushManager.getSubscription();
|
||||
const subscription =
|
||||
existing ??
|
||||
(await registration.pushManager.subscribe({
|
||||
// userVisibleOnly обязателен: браузер требует показывать уведомление.
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
}));
|
||||
return subscriptionBody(subscription);
|
||||
}
|
||||
|
||||
/** Отписывает устройство и возвращает его эндпоинт (для запроса к серверу). */
|
||||
export async function unsubscribeFromPush(): Promise<string | null> {
|
||||
const subscription = await currentPushSubscription();
|
||||
if (subscription === null) {
|
||||
return null;
|
||||
}
|
||||
const endpoint = subscription.endpoint;
|
||||
try {
|
||||
await subscription.unsubscribe();
|
||||
} catch (error) {
|
||||
console.warn('push unsubscribe failed', error);
|
||||
}
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* Слушает переход по клику на уведомление: service worker присылает
|
||||
* сообщение `push-navigate`, а приложение меняет маршрут без перезагрузки.
|
||||
* Возвращает функцию отписки.
|
||||
*/
|
||||
export function listenForPushNavigation(): () => void {
|
||||
if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
|
||||
return () => {};
|
||||
}
|
||||
const listener = (event: MessageEvent): void => {
|
||||
const data = event.data as { type?: unknown; url?: unknown } | null;
|
||||
if (data === null || data.type !== 'push-navigate' || typeof data.url !== 'string') {
|
||||
return;
|
||||
}
|
||||
if (!data.url.startsWith('/')) {
|
||||
return;
|
||||
}
|
||||
window.history.pushState(null, '', data.url);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
};
|
||||
navigator.serviceWorker.addEventListener('message', listener);
|
||||
return () => navigator.serviceWorker.removeEventListener('message', listener);
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { App } from '@/App';
|
||||
import { getQueryClient } from '@/lib/queryClient';
|
||||
import { registerServiceWorker } from '@/lib/serviceWorker';
|
||||
import { listenForPushNavigation } from '@/lib/webPush';
|
||||
import { applyTheme, useUiStore } from '@/stores/ui';
|
||||
import '@/i18n';
|
||||
import '@/index.css';
|
||||
|
||||
applyTheme(useUiStore.getState().theme);
|
||||
registerServiceWorker();
|
||||
// Клик по push-уведомлению переводит уже открытое окно в комнату (Фаза 7).
|
||||
listenForPushNavigation();
|
||||
|
||||
const container = document.getElementById('root');
|
||||
if (!container) {
|
||||
|
||||
@@ -1,22 +1,132 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { fetchPushConfig, pushConfigQueryKey, subscribePush, unsubscribePush } from '@/api/push';
|
||||
import { ErrorNotice } from '@/components/ui/ErrorNotice';
|
||||
import { Button } from '@/components/ui/primitives';
|
||||
import { notificationsEnabled, setNotificationsEnabled } from '@/lib/desktopNotifications';
|
||||
import { platform } from '@/lib/platform';
|
||||
import {
|
||||
currentPushSubscription,
|
||||
notificationPermission,
|
||||
pushSupported,
|
||||
subscribeToPush,
|
||||
unsubscribeFromPush,
|
||||
type PushAvailability,
|
||||
} from '@/lib/webPush';
|
||||
import { SettingsSection } from '@/pages/settings/SettingsSection';
|
||||
|
||||
/**
|
||||
* «Уведомления»: системные уведомления об упоминаниях и личных сообщениях.
|
||||
*
|
||||
* Предпочтение хранится на устройстве (localStorage): в desktop-обёртке
|
||||
* уведомления нативные, в браузере — только при выданном разрешении, а
|
||||
* веб-пуш (Фаза 7) появится отдельным пунктом.
|
||||
* Предпочтение «показывать уведомления» хранится на устройстве (localStorage):
|
||||
* в desktop-обёртке уведомления нативные, в браузере — системные. Отдельно
|
||||
* включается Web Push (Фаза 7): тогда уведомления приходят, даже когда
|
||||
* браузер закрыт, а подписку хранит сервер.
|
||||
*/
|
||||
export function NotificationsSettingsSection() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [enabled, setEnabled] = useState(notificationsEnabled);
|
||||
|
||||
// В обёртке уведомления всегда доступны, в браузере — по разрешению.
|
||||
const supported = platform.kind === 'tauri' || 'Notification' in window;
|
||||
const pushApiSupported = pushSupported();
|
||||
|
||||
const config = useQuery({
|
||||
queryKey: pushConfigQueryKey,
|
||||
queryFn: ({ signal }) => fetchPushConfig(signal),
|
||||
enabled: pushApiSupported,
|
||||
// Ключи меняются только при переустановке инстанса: кэш свеж долго.
|
||||
staleTime: 30 * 60 * 1000,
|
||||
retry: 0,
|
||||
});
|
||||
const pushEnabledOnInstance = config.data?.enabled === true;
|
||||
const publicKey = config.data?.public_key ?? '';
|
||||
|
||||
const [subscriptionState, setSubscriptionState] = useState<'unknown' | 'on' | 'off' | 'denied'>(
|
||||
'unknown',
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
/*
|
||||
* Состояние подписки читаем у браузера: сервер знает только про устройства,
|
||||
* а нам важно именно это. В эффекте — только асинхронная работа: состояние
|
||||
* «не поддерживается» и «на инстансе выключено» вычисляются при отрисовке,
|
||||
* иначе получаются лишние перерисовки (правило react-hooks).
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!pushApiSupported || !config.isSuccess || !pushEnabledOnInstance) {
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void currentPushSubscription().then((subscription) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
if (subscription !== null) {
|
||||
setSubscriptionState('on');
|
||||
return;
|
||||
}
|
||||
setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [pushApiSupported, config.isSuccess, pushEnabledOnInstance]);
|
||||
|
||||
const availability: PushAvailability = !pushApiSupported
|
||||
? 'unsupported'
|
||||
: config.isSuccess && !pushEnabledOnInstance
|
||||
? 'unavailable'
|
||||
: subscriptionState === 'unknown'
|
||||
? 'off'
|
||||
: subscriptionState;
|
||||
|
||||
const enable = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = await subscribeToPush(publicKey);
|
||||
if (body === null) {
|
||||
// Разрешение могли не выдать: уточняем состояние у браузера.
|
||||
setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off');
|
||||
return false;
|
||||
}
|
||||
await subscribePush(body);
|
||||
return true;
|
||||
},
|
||||
onSuccess: (subscribed) => {
|
||||
if (subscribed) {
|
||||
setSubscriptionState('on');
|
||||
}
|
||||
void queryClient.invalidateQueries({ queryKey: pushConfigQueryKey });
|
||||
},
|
||||
});
|
||||
|
||||
const disable = useMutation({
|
||||
mutationFn: async () => {
|
||||
const endpoint = await unsubscribeFromPush();
|
||||
// Даже если браузер уже потерял подписку, сервер должен убрать запись.
|
||||
await unsubscribePush(endpoint ?? undefined);
|
||||
},
|
||||
onSuccess: () => {
|
||||
setSubscriptionState(notificationPermission() === 'denied' ? 'denied' : 'off');
|
||||
},
|
||||
});
|
||||
|
||||
const pushBusy = busy || enable.isPending || disable.isPending;
|
||||
|
||||
const togglePush = (): void => {
|
||||
if (pushBusy) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
if (availability === 'on') {
|
||||
disable.mutate(undefined, { onSettled: () => setBusy(false) });
|
||||
return;
|
||||
}
|
||||
enable.mutate(undefined, { onSettled: () => setBusy(false) });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
@@ -56,6 +166,60 @@ export function NotificationsSettingsSection() {
|
||||
{t('settings.notifications.desktopSettings')}
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{platform.kind === 'tauri' ? null : (
|
||||
<div className="mt-5 border-t border-border/40 pt-4" data-testid="push-settings">
|
||||
<p className="text-sm font-medium">{t('settings.notifications.push')}</p>
|
||||
<p className="mt-1 text-sm text-fg-muted">{t('settings.notifications.pushHint')}</p>
|
||||
|
||||
{availability === 'unsupported' ? (
|
||||
<p className="mt-3 text-sm text-fg-muted" role="status">
|
||||
{t('settings.notifications.pushUnsupported')}
|
||||
</p>
|
||||
) : config.isError ? (
|
||||
<ErrorNotice
|
||||
className="mt-3"
|
||||
error={config.error}
|
||||
onRetry={() => void config.refetch()}
|
||||
/>
|
||||
) : availability === 'unavailable' ? (
|
||||
<p className="mt-3 text-sm text-fg-muted" role="status">
|
||||
{t('settings.notifications.pushUnavailable')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<Button
|
||||
disabled={pushBusy || !config.isSuccess || publicKey === ''}
|
||||
onClick={togglePush}
|
||||
data-testid="push-toggle"
|
||||
>
|
||||
{t(
|
||||
pushBusy
|
||||
? 'settings.notifications.pushWorking'
|
||||
: availability === 'on'
|
||||
? 'settings.notifications.pushDisable'
|
||||
: 'settings.notifications.pushEnable',
|
||||
)}
|
||||
</Button>
|
||||
{availability === 'on' ? (
|
||||
<p className="text-xs text-success" role="status" data-testid="push-active">
|
||||
{t('settings.notifications.pushActive')}
|
||||
</p>
|
||||
) : null}
|
||||
{availability === 'denied' ? (
|
||||
<p className="text-xs text-warning" role="status" data-testid="push-denied">
|
||||
{t('settings.notifications.pushDenied')}
|
||||
</p>
|
||||
) : null}
|
||||
{enable.isError ? (
|
||||
<p className="text-xs text-danger" role="alert">
|
||||
{t('settings.notifications.pushError')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user