mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
fix: recreate the push subscription on re-register and add per-device revoke #841
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, Select } from './settings-section';
|
||||
import { playNotificationSound, NOTIFICATION_SOUNDS } from '@/lib/notification-sound';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Volume2, XCircle } from 'lucide-react';
|
||||
import { Loader2, RefreshCw, Volume2, XCircle } from 'lucide-react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
@@ -18,7 +18,10 @@ import {
|
||||
enableWebPush,
|
||||
isWebPushEnabled,
|
||||
isWebPushSupported,
|
||||
listPushDevices,
|
||||
revokePushDevice,
|
||||
} from '@/lib/web-push';
|
||||
import type { PushDevice } from '@/lib/web-push';
|
||||
import {
|
||||
resolveActiveRelayUrl,
|
||||
resolvePushRelayOptions,
|
||||
@@ -31,6 +34,12 @@ type PushStatus =
|
||||
| { kind: 'unsupported' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
type DevicesState =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'loading' }
|
||||
| { kind: 'loaded'; devices: PushDevice[] }
|
||||
| { kind: 'error' };
|
||||
|
||||
export function NotificationSettings() {
|
||||
const t = useTranslations('settings.notifications');
|
||||
const {
|
||||
@@ -54,6 +63,8 @@ export function NotificationSettings() {
|
||||
const [pushStatus, setPushStatus] = useState<PushStatus>(
|
||||
supported ? { kind: 'idle' } : { kind: 'unsupported' },
|
||||
);
|
||||
const [devices, setDevices] = useState<DevicesState>({ kind: 'idle' });
|
||||
const [revokingId, setRevokingId] = useState<string | null>(null);
|
||||
|
||||
// Relay URLs come from admin policy only - users pick one of the offered
|
||||
// relays, they never type a URL.
|
||||
@@ -74,6 +85,23 @@ export function NotificationSettings() {
|
||||
})();
|
||||
}, [supported, client]);
|
||||
|
||||
// The device list is worth loading even when push is off on this browser -
|
||||
// revoking a stale registration left on another device is exactly what you
|
||||
// come here for.
|
||||
const refreshDevices = useCallback(async () => {
|
||||
if (!client) return;
|
||||
setDevices({ kind: 'loading' });
|
||||
try {
|
||||
setDevices({ kind: 'loaded', devices: await listPushDevices({ client, relayBaseUrl: activeRelayUrl }) });
|
||||
} catch {
|
||||
setDevices({ kind: 'error' });
|
||||
}
|
||||
}, [client, activeRelayUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshDevices();
|
||||
}, [refreshDevices]);
|
||||
|
||||
const busy = pushStatus.kind === 'busy';
|
||||
const pushEnabled = pushStatus.kind === 'enabled';
|
||||
const statusDescription = pushStatus.kind === 'unsupported'
|
||||
@@ -84,7 +112,10 @@ export function NotificationSettings() {
|
||||
? t('push.status_active')
|
||||
: t('push.status_inactive');
|
||||
|
||||
const handleEnablePush = async () => {
|
||||
// `forceRecreate` is what the Re-register button is for: without it the enable
|
||||
// path just refreshes the existing subscription's expiry, so a registration
|
||||
// holding stale shared-mailbox access survives the round trip (#841).
|
||||
const handleEnablePush = async (forceRecreate = false) => {
|
||||
if (!client) {
|
||||
setPushStatus({ kind: 'error', message: 'Sign in first' });
|
||||
return;
|
||||
@@ -95,6 +126,7 @@ export function NotificationSettings() {
|
||||
client,
|
||||
relayBaseUrl: activeRelayUrl,
|
||||
accountLabel: username ?? undefined,
|
||||
forceRecreate,
|
||||
});
|
||||
setPushStatus({ kind: 'enabled' });
|
||||
} catch (err) {
|
||||
@@ -106,6 +138,8 @@ export function NotificationSettings() {
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'Failed to enable push',
|
||||
});
|
||||
} finally {
|
||||
await refreshDevices();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -127,9 +161,45 @@ export function NotificationSettings() {
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'Failed to disable push',
|
||||
});
|
||||
} finally {
|
||||
await refreshDevices();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevokeDevice = async (device: PushDevice) => {
|
||||
if (!client) return;
|
||||
const confirmed = await confirmDialog({
|
||||
title: t('push.confirm_revoke_title'),
|
||||
message: device.isThisDevice
|
||||
? t('push.confirm_revoke_message_this')
|
||||
: t('push.confirm_revoke_message'),
|
||||
confirmText: t('push.revoke'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!confirmed) return;
|
||||
setRevokingId(device.id);
|
||||
try {
|
||||
await revokePushDevice({ client, device, relayBaseUrl: activeRelayUrl });
|
||||
if (device.isThisDevice) setPushStatus({ kind: 'idle' });
|
||||
} catch (err) {
|
||||
setPushStatus({
|
||||
kind: 'error',
|
||||
message: err instanceof Error ? err.message : 'Failed to revoke device',
|
||||
});
|
||||
} finally {
|
||||
setRevokingId(null);
|
||||
await refreshDevices();
|
||||
}
|
||||
};
|
||||
|
||||
// Spelled out rather than interpolated so the translation-coverage test can
|
||||
// see every key that reaches next-intl.
|
||||
const relayStatusLabel = (status: PushDevice['relayStatus']) => {
|
||||
if (status === 'active') return t('push.device_status_active');
|
||||
if (status === 'inactive') return t('push.device_status_inactive');
|
||||
return t('push.device_status_unknown');
|
||||
};
|
||||
|
||||
const soundOptions = NOTIFICATION_SOUNDS.map((s) => ({
|
||||
value: s.id,
|
||||
label: t(`sounds.${s.id}`),
|
||||
@@ -141,7 +211,7 @@ export function NotificationSettings() {
|
||||
<SettingItem label={t('push.enable')} description={statusDescription}>
|
||||
<div className="flex items-center gap-2">
|
||||
{pushEnabled && (
|
||||
<Button variant="ghost" size="sm" onClick={handleEnablePush} disabled={busy}>
|
||||
<Button variant="ghost" size="sm" onClick={() => void handleEnablePush(true)} disabled={busy}>
|
||||
{t('push.reenable')}
|
||||
</Button>
|
||||
)}
|
||||
@@ -178,6 +248,60 @@ export function NotificationSettings() {
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
{client && (
|
||||
<SettingsSection title={t('push.devices_title')} description={t('push.devices_desc')}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{devices.kind === 'loading' && t('push.devices_loading')}
|
||||
{devices.kind === 'error' && t('push.devices_error')}
|
||||
{devices.kind === 'loaded' && devices.devices.length === 0 && t('push.devices_empty')}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshDevices()}
|
||||
disabled={devices.kind === 'loading'}
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 mr-1.5 ${devices.kind === 'loading' ? 'animate-spin' : ''}`} />
|
||||
{t('push.devices_refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{devices.kind === 'loaded' && devices.devices.length > 0 && (
|
||||
<ul className="divide-y divide-border rounded-md border border-border">
|
||||
{devices.devices.map((device) => (
|
||||
<li key={device.id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">
|
||||
{device.isThisDevice ? t('push.device_this') : t('push.device_other')}
|
||||
<span className="ml-1.5 font-mono text-xs font-normal text-muted-foreground">
|
||||
{device.deviceClientId.slice(0, 8)}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{relayStatusLabel(device.relayStatus)}
|
||||
{device.expires
|
||||
? ` · ${t('push.device_expires', { date: new Date(device.expires).toLocaleDateString() })}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={() => void handleRevokeDevice(device)}
|
||||
disabled={revokingId !== null}
|
||||
>
|
||||
{revokingId === device.id && <Loader2 className="w-3.5 h-3.5 mr-1.5 animate-spin" />}
|
||||
{t('push.revoke')}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title={t('sound_selection.title')} description={t('sound_selection.description')}>
|
||||
<SettingItem
|
||||
label={t('sound_selection.choose')}
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { PushSubscription as JmapPushSubscription } from '@/lib/jmap/types';
|
||||
import {
|
||||
disableWebPush,
|
||||
enableWebPush,
|
||||
listPushDevices,
|
||||
revokePushDevice,
|
||||
} from '@/lib/web-push';
|
||||
|
||||
const RELAY = 'https://relay.example';
|
||||
const ACCOUNT_ID = 'acct-1';
|
||||
const THIS_DEVICE = 'aaaaaaaabbbbbbbbccccccccdddddddd';
|
||||
const OTHER_DEVICE = '11111111222222223333333344444444';
|
||||
|
||||
const DEVICE_KEY = `bulwark.push.deviceClientId.v1.${ACCOUNT_ID}`;
|
||||
const SUB_KEY = `bulwark.push.subscriptionId.v1.${ACCOUNT_ID}`;
|
||||
|
||||
function sub(
|
||||
id: string,
|
||||
deviceClientId: string,
|
||||
overrides: Partial<JmapPushSubscription> = {},
|
||||
): JmapPushSubscription {
|
||||
return {
|
||||
id,
|
||||
deviceClientId,
|
||||
url: `${RELAY}/api/push/jmap/${deviceClientId}`,
|
||||
keys: null,
|
||||
expires: new Date(Date.now() + 80 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
types: ['EmailDelivery'],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface FakeClient extends Partial<IJMAPClient> {
|
||||
destroyed: string[];
|
||||
}
|
||||
|
||||
function makeClient(subs: JmapPushSubscription[]): FakeClient & IJMAPClient {
|
||||
const destroyed: string[] = [];
|
||||
const client = {
|
||||
destroyed,
|
||||
getAccountId: () => ACCOUNT_ID,
|
||||
listPushSubscriptions: vi.fn(async () => subs.filter((s) => !destroyed.includes(s.id))),
|
||||
createPushSubscription: vi.fn(async () => 'push-new'),
|
||||
verifyPushSubscription: vi.fn(async () => undefined),
|
||||
updatePushSubscription: vi.fn(async () => true),
|
||||
destroyPushSubscription: vi.fn(async (id: string) => {
|
||||
destroyed.push(id);
|
||||
}),
|
||||
};
|
||||
return client as unknown as FakeClient & IJMAPClient;
|
||||
}
|
||||
|
||||
// enableWebPush needs a browser that supports push; jsdom has none of it.
|
||||
function installPushBrowser() {
|
||||
const browserSub = {
|
||||
endpoint: 'https://fcm.example/endpoint',
|
||||
options: { applicationServerKey: new ArrayBuffer(8) },
|
||||
getKey: () => new Uint8Array([1, 2, 3]).buffer,
|
||||
unsubscribe: vi.fn(async () => true),
|
||||
};
|
||||
const registration = {
|
||||
pushManager: {
|
||||
getSubscription: vi.fn(async () => browserSub),
|
||||
subscribe: vi.fn(async () => browserSub),
|
||||
},
|
||||
};
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getRegistration: vi.fn(async () => registration),
|
||||
register: vi.fn(async () => registration),
|
||||
ready: Promise.resolve(registration),
|
||||
},
|
||||
});
|
||||
(window as unknown as { PushManager: unknown }).PushManager = class {};
|
||||
(window as unknown as { Notification: unknown }).Notification = { permission: 'granted' };
|
||||
(globalThis as unknown as { Notification: unknown }).Notification = { permission: 'granted' };
|
||||
return { browserSub, registration };
|
||||
}
|
||||
|
||||
// Relay endpoints enableWebPush walks through, plus a per-device liveness answer.
|
||||
function installFetch(activeByDevice: Record<string, boolean | 'unknown'>) {
|
||||
const calls: { url: string; method: string }[] = [];
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, method: init?.method ?? 'GET' });
|
||||
const json = (body: unknown, status = 200) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
if (url.endsWith('/api/push/vapid-public-key')) return json({ publicKey: 'QUJD' });
|
||||
if (url.includes('/api/push/verify/')) return json({ verificationCode: 'code-123' });
|
||||
if (url.includes('/api/push/active/')) {
|
||||
const device = url.split('/api/push/active/')[1];
|
||||
const state = activeByDevice[device];
|
||||
if (state === undefined || state === 'unknown') return json({ error: 'Unknown' }, 404);
|
||||
return json({ active: state });
|
||||
}
|
||||
return json({});
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
return calls;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
installPushBrowser();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('enableWebPush', () => {
|
||||
it('reuses the recorded subscription by default', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
const result = await enableWebPush({ client, relayBaseUrl: RELAY });
|
||||
|
||||
expect(result.subscriptionId).toBe('push-old');
|
||||
expect(client.createPushSubscription).not.toHaveBeenCalled();
|
||||
expect(client.destroyed).toEqual([]);
|
||||
});
|
||||
|
||||
it('destroys and recreates the recorded subscription with forceRecreate', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-old');
|
||||
const client = makeClient([sub('push-old', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
const result = await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true });
|
||||
|
||||
expect(client.destroyed).toContain('push-old');
|
||||
expect(client.createPushSubscription).toHaveBeenCalledTimes(1);
|
||||
expect(client.verifyPushSubscription).toHaveBeenCalledWith('push-new', 'code-123');
|
||||
expect(result.subscriptionId).toBe('push-new');
|
||||
expect(localStorage.getItem(SUB_KEY)).toBe('push-new');
|
||||
});
|
||||
|
||||
it('leaves another device alone when the relay cannot vouch for it', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
const client = makeClient([sub('push-other', OTHER_DEVICE)]);
|
||||
installFetch({ [OTHER_DEVICE]: 'unknown' });
|
||||
|
||||
await enableWebPush({ client, relayBaseUrl: RELAY, forceRecreate: true });
|
||||
|
||||
expect(client.destroyed).not.toContain('push-other');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableWebPush', () => {
|
||||
it('destroys every subscription this device owns, not just the recorded id', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-tracked');
|
||||
const client = makeClient([
|
||||
sub('push-tracked', THIS_DEVICE),
|
||||
// Left behind by an earlier enable whose destroy never landed.
|
||||
sub('push-orphan', THIS_DEVICE),
|
||||
sub('push-other', OTHER_DEVICE),
|
||||
]);
|
||||
installFetch({});
|
||||
|
||||
await disableWebPush({ client, relayBaseUrl: RELAY });
|
||||
|
||||
expect(client.destroyed.sort()).toEqual(['push-orphan', 'push-tracked']);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
// The deviceClientId survives so a later enable reuses the relay record.
|
||||
expect(localStorage.getItem(DEVICE_KEY)).toBe(THIS_DEVICE);
|
||||
});
|
||||
|
||||
it('drops the relay registration for this device', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
const client = makeClient([]);
|
||||
const calls = installFetch({});
|
||||
|
||||
await disableWebPush({ client, relayBaseUrl: RELAY });
|
||||
|
||||
expect(calls).toContainEqual({
|
||||
url: `${RELAY}/api/push/register/${THIS_DEVICE}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPushDevices', () => {
|
||||
it('flags this device and reports the relay status of each registration', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
const client = makeClient([
|
||||
sub('push-mine', THIS_DEVICE),
|
||||
sub('push-live', OTHER_DEVICE),
|
||||
sub('push-dead', 'deaddeaddeaddeaddeaddeaddeaddead'),
|
||||
sub('push-foreign', 'ffffffffffffffffffffffffffffffff'),
|
||||
]);
|
||||
installFetch({
|
||||
[THIS_DEVICE]: true,
|
||||
[OTHER_DEVICE]: true,
|
||||
deaddeaddeaddeaddeaddeaddeaddead: false,
|
||||
});
|
||||
|
||||
const devices = await listPushDevices({ client, relayBaseUrl: RELAY });
|
||||
|
||||
expect(devices.map((d) => [d.id, d.isThisDevice, d.relayStatus])).toEqual([
|
||||
['push-mine', true, 'active'],
|
||||
['push-live', false, 'active'],
|
||||
['push-dead', false, 'inactive'],
|
||||
['push-foreign', false, 'unknown'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revokePushDevice', () => {
|
||||
it('destroys another device and frees its relay registration', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE), sub('push-other', OTHER_DEVICE)]);
|
||||
const calls = installFetch({});
|
||||
|
||||
await revokePushDevice({
|
||||
client,
|
||||
relayBaseUrl: RELAY,
|
||||
device: { id: 'push-other', deviceClientId: OTHER_DEVICE, isThisDevice: false },
|
||||
});
|
||||
|
||||
expect(client.destroyed).toEqual(['push-other']);
|
||||
expect(calls).toContainEqual({
|
||||
url: `${RELAY}/api/push/register/${OTHER_DEVICE}`,
|
||||
method: 'DELETE',
|
||||
});
|
||||
// Revoking elsewhere must not disturb this browser's own registration.
|
||||
expect(localStorage.getItem(SUB_KEY)).toBe('push-mine');
|
||||
});
|
||||
|
||||
it('runs the full local teardown when revoking this device', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
localStorage.setItem(SUB_KEY, 'push-mine');
|
||||
const client = makeClient([sub('push-mine', THIS_DEVICE)]);
|
||||
installFetch({});
|
||||
|
||||
await revokePushDevice({
|
||||
client,
|
||||
relayBaseUrl: RELAY,
|
||||
device: { id: 'push-mine', deviceClientId: THIS_DEVICE, isThisDevice: true },
|
||||
});
|
||||
|
||||
expect(client.destroyed).toEqual(['push-mine']);
|
||||
expect(localStorage.getItem(SUB_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
+145
-27
@@ -55,6 +55,12 @@ export interface EnableWebPushParams {
|
||||
relayBaseUrl?: string;
|
||||
// Free-form label the relay shows in /metrics; never returned in pushes.
|
||||
accountLabel?: string;
|
||||
// Destroy the recorded server-side subscription and create a brand-new one
|
||||
// instead of refreshing the existing record's expiry. Stalwart binds the set
|
||||
// of accounts a subscription fans out to at creation time, so a subscription
|
||||
// that outlives a permission change keeps pushing for mailboxes the user can
|
||||
// no longer read - recreating is the only client-side remedy (#841).
|
||||
forceRecreate?: boolean;
|
||||
}
|
||||
|
||||
export interface EnableWebPushResult {
|
||||
@@ -202,31 +208,47 @@ async function registerWithRelay(params: {
|
||||
}
|
||||
}
|
||||
|
||||
/** The relay's view of a subscription - see relayStatusFor. */
|
||||
export type PushRelayStatus = 'active' | 'inactive' | 'unknown';
|
||||
|
||||
/**
|
||||
* Ask the relay whether a leftover subscription is dead. Returns true ONLY when
|
||||
* the relay positively reports it inactive - a record it knows about that has
|
||||
* never forwarded a push and isn't freshly registered. Every other outcome (the
|
||||
* relay doesn't recognise the id, an older relay without this endpoint, a
|
||||
* network blip, or a live subscription) returns false, so we never reap
|
||||
* anything we can't confirm is dead. This lets enableWebPush clear its own
|
||||
* abandoned attempts - and dead siblings left by cleared site data that
|
||||
* regenerated the deviceClientId - without disturbing another live device or
|
||||
* the mobile app that shares the account.
|
||||
* Ask the relay what it knows about a subscription. `inactive` means the relay
|
||||
* recognises the record and it is provably dead - it has never forwarded a push
|
||||
* and isn't freshly registered. `unknown` covers everything we cannot vouch for:
|
||||
* the relay doesn't recognise the id, an older relay without this endpoint, or a
|
||||
* network blip. Callers must treat `unknown` as "leave it alone", never as dead.
|
||||
*/
|
||||
async function relayStatusFor(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<PushRelayStatus> {
|
||||
if (!relayBaseUrl || !subscriptionId) return 'unknown';
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (!res.ok) return 'unknown';
|
||||
const body = (await res.json()) as { active?: unknown };
|
||||
if (body.active === true) return 'active';
|
||||
if (body.active === false) return 'inactive';
|
||||
return 'unknown';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true ONLY when the relay positively reports a subscription inactive,
|
||||
* so we never reap anything we can't confirm is dead. This lets enableWebPush
|
||||
* clear its own abandoned attempts - and dead siblings left by cleared site data
|
||||
* that regenerated the deviceClientId - without disturbing another live device
|
||||
* or the mobile app that shares the account.
|
||||
*/
|
||||
async function relayReportsDead(
|
||||
relayBaseUrl: string,
|
||||
subscriptionId: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/active/${encodeURIComponent(subscriptionId)}`),
|
||||
);
|
||||
if (!res.ok) return false;
|
||||
const body = (await res.json()) as { active?: unknown };
|
||||
return body.active === false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return (await relayStatusFor(relayBaseUrl, subscriptionId)) === 'inactive';
|
||||
}
|
||||
|
||||
async function pollVerificationCode(
|
||||
@@ -326,15 +348,21 @@ export async function enableWebPush(
|
||||
});
|
||||
|
||||
// Reuse the JMAP-side PushSubscription if the server still has it, just
|
||||
// refreshing the expiry so it doesn't time out between sessions.
|
||||
// refreshing the expiry so it doesn't time out between sessions. With
|
||||
// forceRecreate we skip the reuse and destroy it instead: the account set a
|
||||
// Stalwart subscription fans out to is fixed at creation time, so refreshing
|
||||
// `expires` carries stale permissions forward and only a new record picks up
|
||||
// revoked shared-mailbox access (#841).
|
||||
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
|
||||
const subIdKey = subscriptionIdKey(accountId);
|
||||
const storedServerId = localStorage.getItem(subIdKey);
|
||||
if (storedServerId) {
|
||||
const match = existingSubs.find((s) => s.id === storedServerId);
|
||||
if (match) {
|
||||
const refreshed = await refreshSubscriptionExpires(params.client, match);
|
||||
if (refreshed) return { subscriptionId: storedServerId };
|
||||
if (!params.forceRecreate) {
|
||||
const refreshed = await refreshSubscriptionExpires(params.client, match);
|
||||
if (refreshed) return { subscriptionId: storedServerId };
|
||||
}
|
||||
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
}
|
||||
localStorage.removeItem(subIdKey);
|
||||
@@ -396,12 +424,26 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
||||
const devIdKey = deviceClientIdKey(accountId);
|
||||
|
||||
const storedServerId = localStorage.getItem(subIdKey);
|
||||
if (storedServerId) {
|
||||
await params.client.destroyPushSubscription(storedServerId).catch(() => undefined);
|
||||
localStorage.removeItem(subIdKey);
|
||||
}
|
||||
|
||||
const deviceClientId = localStorage.getItem(devIdKey);
|
||||
|
||||
// Destroy every subscription the server holds for this device, not just the
|
||||
// id we happen to have recorded. A destroy that lost its round-trip, a failed
|
||||
// enable, or site data cleared between sessions can leave a registration this
|
||||
// client no longer tracks - and Stalwart keeps fanning StateChanges out to it
|
||||
// until it expires, so "disable" has to mean gone (#841).
|
||||
const idsToDestroy = new Set<string>();
|
||||
if (storedServerId) idsToDestroy.add(storedServerId);
|
||||
if (deviceClientId) {
|
||||
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
|
||||
for (const s of existingSubs) {
|
||||
if (s.deviceClientId === deviceClientId) idsToDestroy.add(s.id);
|
||||
}
|
||||
}
|
||||
for (const id of idsToDestroy) {
|
||||
await params.client.destroyPushSubscription(id).catch(() => undefined);
|
||||
}
|
||||
localStorage.removeItem(subIdKey);
|
||||
|
||||
if (deviceClientId && relayBaseUrl) {
|
||||
await fetch(
|
||||
buildRelayUrl(relayBaseUrl, `/api/push/register/${encodeURIComponent(deviceClientId)}`),
|
||||
@@ -424,6 +466,82 @@ export async function disableWebPush(params: DisableWebPushParams): Promise<void
|
||||
}
|
||||
}
|
||||
|
||||
export interface PushDevice {
|
||||
// The JMAP PushSubscription id - what you destroy to revoke it.
|
||||
id: string;
|
||||
// Client-chosen id the relay keys its endpoint mapping on.
|
||||
deviceClientId: string;
|
||||
expires: string | null;
|
||||
types: string[] | null;
|
||||
// True when this registration belongs to the browser you're looking at.
|
||||
isThisDevice: boolean;
|
||||
relayStatus: PushRelayStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every push registration the JMAP server holds for this account, annotated
|
||||
* with whether it is this browser and what the relay makes of it.
|
||||
*
|
||||
* Stalwart hides a subscription's url and verified state from clients, so
|
||||
* deviceClientId is the only handle we get. That's enough to spot our own
|
||||
* registration and to ask the relay about the rest - but registrations made
|
||||
* against a different relay, or by a non-Bulwark client, come back `unknown`
|
||||
* rather than dead, and the UI must present them as revocable-but-unclassified.
|
||||
*/
|
||||
export async function listPushDevices(params: {
|
||||
client: IJMAPClient;
|
||||
relayBaseUrl?: string;
|
||||
}): Promise<PushDevice[]> {
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
const accountId = params.client.getAccountId();
|
||||
const thisDeviceClientId = typeof localStorage === 'undefined'
|
||||
? null
|
||||
: localStorage.getItem(deviceClientIdKey(accountId));
|
||||
|
||||
const subs = await params.client.listPushSubscriptions();
|
||||
return Promise.all(
|
||||
subs.map(async (s) => ({
|
||||
id: s.id,
|
||||
deviceClientId: s.deviceClientId,
|
||||
expires: s.expires ?? null,
|
||||
types: s.types ?? null,
|
||||
isThisDevice: thisDeviceClientId !== null && s.deviceClientId === thisDeviceClientId,
|
||||
relayStatus: await relayStatusFor(relayBaseUrl, s.deviceClientId),
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke one registration. Destroying the JMAP subscription stops the server
|
||||
* fanning StateChanges to it; dropping the relay mapping stops the relay
|
||||
* forwarding anything already in flight and frees the deviceClientId. Revoking
|
||||
* this device runs the full local teardown so the UI doesn't keep claiming push
|
||||
* is on.
|
||||
*/
|
||||
export async function revokePushDevice(params: {
|
||||
client: IJMAPClient;
|
||||
device: Pick<PushDevice, 'id' | 'deviceClientId' | 'isThisDevice'>;
|
||||
relayBaseUrl?: string;
|
||||
}): Promise<void> {
|
||||
const relayBaseUrl = (params.relayBaseUrl ?? DEFAULT_RELAY_BASE_URL).replace(/\/+$/, '');
|
||||
|
||||
if (params.device.isThisDevice) {
|
||||
await disableWebPush({ client: params.client, relayBaseUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
await params.client.destroyPushSubscription(params.device.id);
|
||||
if (relayBaseUrl && params.device.deviceClientId) {
|
||||
await fetch(
|
||||
buildRelayUrl(
|
||||
relayBaseUrl,
|
||||
`/api/push/register/${encodeURIComponent(params.device.deviceClientId)}`,
|
||||
),
|
||||
{ method: 'DELETE' },
|
||||
).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
export async function isWebPushEnabled(accountId: string): Promise<boolean> {
|
||||
if (!isWebPushSupported()) return false;
|
||||
if (Notification.permission !== 'granted') return false;
|
||||
|
||||
+17
-1
@@ -1090,7 +1090,23 @@
|
||||
"confirm_disable_title": "تعطيل الإشعارات في الخلفية؟",
|
||||
"confirm_disable_message": "سيتوقف هذا الجهاز عن تلقي التنبيهات عند إغلاق الموقع.",
|
||||
"dismiss_aria": "تجاهل مطالبة الإشعارات",
|
||||
"ios_hint": "على iOS، ثبّت الموقع على الشاشة الرئيسية أولًا - يقدّم Safari خدمة Web Push فقط لتطبيقات PWA المثبّتة."
|
||||
"ios_hint": "على iOS، ثبّت الموقع على الشاشة الرئيسية أولًا - يقدّم Safari خدمة Web Push فقط لتطبيقات PWA المثبّتة.",
|
||||
"devices_title": "الأجهزة المسجّلة",
|
||||
"devices_desc": "كل الأجهزة المسجّلة لتلقّي الإشعارات في الخلفية لهذا الحساب. إلغاء أي جهاز يوقف تلقّيه فورًا.",
|
||||
"devices_loading": "جارٍ تحميل الأجهزة المسجّلة…",
|
||||
"devices_empty": "لا توجد أجهزة مسجّلة للإشعارات في الخلفية.",
|
||||
"devices_error": "تعذّر تحميل الأجهزة المسجّلة.",
|
||||
"devices_refresh": "تحديث",
|
||||
"device_this": "هذا الجهاز",
|
||||
"device_other": "جهاز آخر",
|
||||
"device_status_active": "يسلّم الإشعارات",
|
||||
"device_status_inactive": "لم يسلّم أي إشعار قط",
|
||||
"device_status_unknown": "غير معروف لهذا المُرحّل",
|
||||
"device_expires": "ينتهي في {date}",
|
||||
"revoke": "إلغاء التسجيل",
|
||||
"confirm_revoke_title": "إلغاء تسجيل هذا الجهاز؟",
|
||||
"confirm_revoke_message": "سيتوقّف فورًا عن تلقّي الإشعارات في الخلفية. تفعيل الدفع مجددًا على ذلك الجهاز سيسجّله من جديد.",
|
||||
"confirm_revoke_message_this": "سيتوقّف هذا الجهاز فورًا عن تلقّي الإشعارات في الخلفية."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "صوت الإشعار",
|
||||
|
||||
+17
-1
@@ -1090,7 +1090,23 @@
|
||||
"confirm_disable_title": "Voleu desactivar les notificacions en segon pla?",
|
||||
"confirm_disable_message": "Aquest dispositiu deixarà de rebre avisos quan el lloc estigui tancat.",
|
||||
"dismiss_aria": "Descarta l'avís de notificacions",
|
||||
"ios_hint": "A l'iOS, primer instal·leu el lloc a la pantalla d'inici - el Safari només entrega Web Push a les PWA instal·lades."
|
||||
"ios_hint": "A l'iOS, primer instal·leu el lloc a la pantalla d'inici - el Safari només entrega Web Push a les PWA instal·lades.",
|
||||
"devices_title": "Dispositius registrats",
|
||||
"devices_desc": "Tots els dispositius registrats per rebre notificacions en segon pla d’aquest compte. Si en revoques un, deixa de rebre-les immediatament.",
|
||||
"devices_loading": "S’estan carregant els dispositius registrats…",
|
||||
"devices_empty": "No hi ha cap dispositiu registrat per a les notificacions en segon pla.",
|
||||
"devices_error": "No s’han pogut carregar els dispositius registrats.",
|
||||
"devices_refresh": "Actualitza",
|
||||
"device_this": "Aquest dispositiu",
|
||||
"device_other": "Un altre dispositiu",
|
||||
"device_status_active": "Lliura notificacions",
|
||||
"device_status_inactive": "Mai no ha lliurat cap notificació",
|
||||
"device_status_unknown": "Desconegut per a aquest relé",
|
||||
"device_expires": "Caduca el {date}",
|
||||
"revoke": "Revoca",
|
||||
"confirm_revoke_title": "Voleu revocar aquest dispositiu?",
|
||||
"confirm_revoke_message": "Deixarà de rebre notificacions en segon pla immediatament. Si hi tornes a activar les notificacions push, es registrarà de nou.",
|
||||
"confirm_revoke_message_this": "Aquest dispositiu deixarà de rebre notificacions en segon pla immediatament."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "So de notificació",
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Pracuji…",
|
||||
"status_inactive": "Není povoleno na tomto zařízení",
|
||||
"status_unsupported": "Tento prohlížeč nepodporuje Web Push",
|
||||
"title": "Oznámení na pozadí"
|
||||
"title": "Oznámení na pozadí",
|
||||
"devices_title": "Registrovaná zařízení",
|
||||
"devices_desc": "Všechna zařízení registrovaná pro příjem oznamení na pozadí u tohoto účtu. Odvolání okamžitě zastaví jejich příjem.",
|
||||
"devices_loading": "Načítání registrovaných zařízení…",
|
||||
"devices_empty": "Pro oznámení na pozadí není registrováno žádné zařízení.",
|
||||
"devices_error": "Registrovaná zařízení se nepodařilo načíst.",
|
||||
"devices_refresh": "Obnovit",
|
||||
"device_this": "Toto zařízení",
|
||||
"device_other": "Jiné zařízení",
|
||||
"device_status_active": "Doručuje oznámení",
|
||||
"device_status_inactive": "Nikdy nedoručilo oznámení",
|
||||
"device_status_unknown": "Tomuto relay není známo",
|
||||
"device_expires": "Vyprší {date}",
|
||||
"revoke": "Odvolat",
|
||||
"confirm_revoke_title": "Odvolat toto zařízení?",
|
||||
"confirm_revoke_message": "Okamžitě přestane přijímat oznámení na pozadí. Opětovné zapnutí push na tom zařízení jej zaregistruje znovu.",
|
||||
"confirm_revoke_message_this": "Toto zařízení okamžitě přestane přijímat oznámení na pozadí."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1090,7 +1090,23 @@
|
||||
"confirm_disable_title": "Deaktivér baggrundsnotifikationer?",
|
||||
"confirm_disable_message": "Denne enhed stopper med at modtage alarmer, når sitet er lukket.",
|
||||
"dismiss_aria": "Afvis notifikationsprompt",
|
||||
"ios_hint": "På iOS skal du installere sitet på din startskærm først - Safari leverer kun Web Push til installerede PWA'er."
|
||||
"ios_hint": "På iOS skal du installere sitet på din startskærm først - Safari leverer kun Web Push til installerede PWA'er.",
|
||||
"devices_title": "Registrerede enheder",
|
||||
"devices_desc": "Alle enheder, der er registreret til at modtage baggrundsnotifikationer for denne konto. Tilbagekalder du en, stopper den med at modtage med det samme.",
|
||||
"devices_loading": "Indlæser registrerede enheder…",
|
||||
"devices_empty": "Ingen enheder er registreret til baggrundsnotifikationer.",
|
||||
"devices_error": "Kunne ikke indlæse de registrerede enheder.",
|
||||
"devices_refresh": "Opdater",
|
||||
"device_this": "Denne enhed",
|
||||
"device_other": "Anden enhed",
|
||||
"device_status_active": "Leverer notifikationer",
|
||||
"device_status_inactive": "Har aldrig leveret en notifikation",
|
||||
"device_status_unknown": "Ukendt for dette relæ",
|
||||
"device_expires": "Udløber {date}",
|
||||
"revoke": "Tilbagekald",
|
||||
"confirm_revoke_title": "Tilbagekald denne enhed?",
|
||||
"confirm_revoke_message": "Den stopper med at modtage baggrundsnotifikationer med det samme. Slår du push til igen på enheden, registreres den på ny.",
|
||||
"confirm_revoke_message_this": "Denne enhed stopper med at modtage baggrundsnotifikationer med det samme."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "Notifikationslyd",
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Wird verarbeitet…",
|
||||
"status_inactive": "Auf diesem Gerät nicht aktiviert",
|
||||
"status_unsupported": "Dieser Browser unterstützt Web Push nicht",
|
||||
"title": "Hintergrundbenachrichtigungen"
|
||||
"title": "Hintergrundbenachrichtigungen",
|
||||
"devices_title": "Registrierte Geräte",
|
||||
"devices_desc": "Alle Geräte, die Hintergrundbenachrichtigungen für dieses Konto erhalten. Wird eines widerrufen, empfängt es sofort keine mehr.",
|
||||
"devices_loading": "Registrierte Geräte werden geladen…",
|
||||
"devices_empty": "Für Hintergrundbenachrichtigungen ist kein Gerät registriert.",
|
||||
"devices_error": "Registrierte Geräte konnten nicht geladen werden.",
|
||||
"devices_refresh": "Aktualisieren",
|
||||
"device_this": "Dieses Gerät",
|
||||
"device_other": "Anderes Gerät",
|
||||
"device_status_active": "Stellt Benachrichtigungen zu",
|
||||
"device_status_inactive": "Hat nie eine Benachrichtigung zugestellt",
|
||||
"device_status_unknown": "Diesem Relay nicht bekannt",
|
||||
"device_expires": "Läuft am {date} ab",
|
||||
"revoke": "Widerrufen",
|
||||
"confirm_revoke_title": "Dieses Gerät widerrufen?",
|
||||
"confirm_revoke_message": "Es empfängt sofort keine Hintergrundbenachrichtigungen mehr. Wird Push auf diesem Gerät erneut aktiviert, wird es neu registriert.",
|
||||
"confirm_revoke_message_this": "Dieses Gerät empfängt sofort keine Hintergrundbenachrichtigungen mehr."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1093,7 +1093,23 @@
|
||||
"confirm_disable_title": "Disable background notifications?",
|
||||
"confirm_disable_message": "This device will stop receiving alerts when the site is closed.",
|
||||
"dismiss_aria": "Dismiss notification prompt",
|
||||
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs."
|
||||
"ios_hint": "On iOS, install the site to your home screen first - Safari only delivers Web Push to installed PWAs.",
|
||||
"devices_title": "Registered devices",
|
||||
"devices_desc": "Every device registered to receive background notifications for this account. Revoking one stops it receiving immediately.",
|
||||
"devices_loading": "Loading registered devices…",
|
||||
"devices_empty": "No devices are registered for background notifications.",
|
||||
"devices_error": "Could not load registered devices.",
|
||||
"devices_refresh": "Refresh",
|
||||
"device_this": "This device",
|
||||
"device_other": "Other device",
|
||||
"device_status_active": "Delivering notifications",
|
||||
"device_status_inactive": "Never delivered a notification",
|
||||
"device_status_unknown": "Not known to this relay",
|
||||
"device_expires": "Expires {date}",
|
||||
"revoke": "Revoke",
|
||||
"confirm_revoke_title": "Revoke this device?",
|
||||
"confirm_revoke_message": "It will stop receiving background notifications immediately. Enabling push again on that device registers it anew.",
|
||||
"confirm_revoke_message_this": "This device will stop receiving background notifications immediately."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "Notification Sound",
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Trabajando…",
|
||||
"status_inactive": "No habilitado en este dispositivo",
|
||||
"status_unsupported": "Este navegador no admite Web Push",
|
||||
"title": "Notificaciones en segundo plano"
|
||||
"title": "Notificaciones en segundo plano",
|
||||
"devices_title": "Dispositivos registrados",
|
||||
"devices_desc": "Todos los dispositivos registrados para recibir notificaciones en segundo plano de esta cuenta. Al revocar uno, deja de recibirlas de inmediato.",
|
||||
"devices_loading": "Cargando dispositivos registrados…",
|
||||
"devices_empty": "No hay dispositivos registrados para notificaciones en segundo plano.",
|
||||
"devices_error": "No se pudieron cargar los dispositivos registrados.",
|
||||
"devices_refresh": "Actualizar",
|
||||
"device_this": "Este dispositivo",
|
||||
"device_other": "Otro dispositivo",
|
||||
"device_status_active": "Entregando notificaciones",
|
||||
"device_status_inactive": "Nunca ha entregado una notificación",
|
||||
"device_status_unknown": "Desconocido para este relé",
|
||||
"device_expires": "Caduca el {date}",
|
||||
"revoke": "Revocar",
|
||||
"confirm_revoke_title": "¿Revocar este dispositivo?",
|
||||
"confirm_revoke_message": "Dejará de recibir notificaciones en segundo plano de inmediato. Si vuelves a activar las notificaciones push en ese dispositivo, se registrará de nuevo.",
|
||||
"confirm_revoke_message_this": "Este dispositivo dejará de recibir notificaciones en segundo plano de inmediato."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1090,7 +1090,23 @@
|
||||
"confirm_disable_title": "غیرفعال کردن اعلانهای پسزمینه؟",
|
||||
"confirm_disable_message": "این دستگاه دیگر هشدار دریافت نمیکند",
|
||||
"dismiss_aria": "رد کردن پیشنهاد اعلانها",
|
||||
"ios_hint": "در iOS ابتدا سایت را به صفحه اصلی اضافه کنید"
|
||||
"ios_hint": "در iOS ابتدا سایت را به صفحه اصلی اضافه کنید",
|
||||
"devices_title": "دستگاههای ثبتشده",
|
||||
"devices_desc": "همهٔ دستگاههایی که برای دریافت اعلانهای پسزمینهٔ این حساب ثبت شدهاند. با لغو یک دستگاه، دریافت بیدرنگ متوقف میشود.",
|
||||
"devices_loading": "در حال بارگذاری دستگاههای ثبتشده…",
|
||||
"devices_empty": "هیچ دستگاهی برای اعلانهای پسزمینه ثبت نشده است.",
|
||||
"devices_error": "بارگذاری دستگاههای ثبتشده ممکن نشد.",
|
||||
"devices_refresh": "بازخوانی",
|
||||
"device_this": "این دستگاه",
|
||||
"device_other": "دستگاه دیگر",
|
||||
"device_status_active": "در حال تحویل اعلانها",
|
||||
"device_status_inactive": "هرگز اعلانی تحویل نداده است",
|
||||
"device_status_unknown": "برای این بازپخش ناشناس",
|
||||
"device_expires": "انقضا در {date}",
|
||||
"revoke": "لغو",
|
||||
"confirm_revoke_title": "این دستگاه لغو شود؟",
|
||||
"confirm_revoke_message": "بیدرنگ دریافت اعلانهای پسزمینه را متوقف میکند. با روشنکردن دوبارهٔ پوش روی آن دستگاه، دوباره ثبت میشود.",
|
||||
"confirm_revoke_message_this": "این دستگاه بیدرنگ دریافت اعلانهای پسزمینه را متوقف میکند."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "صدای اعلان",
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Traitement en cours…",
|
||||
"status_inactive": "Non activé sur cet appareil",
|
||||
"status_unsupported": "Ce navigateur ne prend pas en charge Web Push",
|
||||
"title": "Notifications en arrière-plan"
|
||||
"title": "Notifications en arrière-plan",
|
||||
"devices_title": "Appareils enregistrés",
|
||||
"devices_desc": "Tous les appareils enregistrés pour recevoir les notifications en arrière-plan de ce compte. Révoquer un appareil l’empêche immédiatement d’en recevoir.",
|
||||
"devices_loading": "Chargement des appareils enregistrés…",
|
||||
"devices_empty": "Aucun appareil n’est enregistré pour les notifications en arrière-plan.",
|
||||
"devices_error": "Impossible de charger les appareils enregistrés.",
|
||||
"devices_refresh": "Actualiser",
|
||||
"device_this": "Cet appareil",
|
||||
"device_other": "Autre appareil",
|
||||
"device_status_active": "Distribue les notifications",
|
||||
"device_status_inactive": "N’a jamais distribué de notification",
|
||||
"device_status_unknown": "Inconnu de ce relais",
|
||||
"device_expires": "Expire le {date}",
|
||||
"revoke": "Révoquer",
|
||||
"confirm_revoke_title": "Révoquer cet appareil ?",
|
||||
"confirm_revoke_message": "Il cessera immédiatement de recevoir les notifications en arrière-plan. Réactiver les notifications push sur cet appareil l’enregistrera à nouveau.",
|
||||
"confirm_revoke_message_this": "Cet appareil cessera immédiatement de recevoir les notifications en arrière-plan."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1076,7 +1076,23 @@
|
||||
"confirm_disable_title": "השבת התראות בדפדפן?",
|
||||
"confirm_disable_message": "התקן זה יפסיק לקבל התראות כאשר האתר סגור.",
|
||||
"dismiss_aria": "בטל הודעת התראות",
|
||||
"ios_hint": "ב־iOS, התקן קודם כל את האתר בהיתוך שלך - Safari מספק Web Push רק לPWA מותקנות."
|
||||
"ios_hint": "ב־iOS, התקן קודם כל את האתר בהיתוך שלך - Safari מספק Web Push רק לPWA מותקנות.",
|
||||
"devices_title": "מכשירים רשומים",
|
||||
"devices_desc": "כל המכשירים הרשומים לקבלת התראות ברקע עבור חשבון זה. ביטול מכשיר מפסיק את הקבלה מיד.",
|
||||
"devices_loading": "טוען מכשירים רשומים…",
|
||||
"devices_empty": "אין מכשירים רשומים להתראות ברקע.",
|
||||
"devices_error": "לא ניתן לטעון את המכשירים הרשומים.",
|
||||
"devices_refresh": "רענון",
|
||||
"device_this": "מכשיר זה",
|
||||
"device_other": "מכשיר אחר",
|
||||
"device_status_active": "מספק התראות",
|
||||
"device_status_inactive": "מעולם לא סיפק התראה",
|
||||
"device_status_unknown": "לא מוכר לממסר הזה",
|
||||
"device_expires": "פג בתאריך {date}",
|
||||
"revoke": "ביטול",
|
||||
"confirm_revoke_title": "לבטל את המכשיר הזה?",
|
||||
"confirm_revoke_message": "הוא יפסיק לקבל התראות ברקע מיד. הפעלה מחדש של פוש באותו מכשיר תרשום אותו מחדש.",
|
||||
"confirm_revoke_message_this": "מכשיר זה יפסיק לקבל התראות ברקע מיד."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1090,7 +1090,23 @@
|
||||
"confirm_disable_title": "Háttérértesítések letiltása?",
|
||||
"confirm_disable_message": "Ez az eszköz nem fog figyelmeztetéseket kapni, amikor az oldal be van zárva.",
|
||||
"dismiss_aria": "Értesítési ablak elutasítása",
|
||||
"ios_hint": "iOS-en először telepítsd az oldalt a kezdőképernyőre - a Safari csak telepített PWA-knak kézbesít Web Push értesítéseket."
|
||||
"ios_hint": "iOS-en először telepítsd az oldalt a kezdőképernyőre - a Safari csak telepített PWA-knak kézbesít Web Push értesítéseket.",
|
||||
"devices_title": "Regisztrált eszközök",
|
||||
"devices_desc": "Minden eszköz, amely háttérértesítéseket kap ehhez a fiókhoz. A visszavonás után az adott eszköz azonnal nem kap többet.",
|
||||
"devices_loading": "Regisztrált eszközök betöltése…",
|
||||
"devices_empty": "Nincs eszköz regisztrálva háttérértesítésekhez.",
|
||||
"devices_error": "A regisztrált eszközöket nem sikerült betölteni.",
|
||||
"devices_refresh": "Frissítés",
|
||||
"device_this": "Ez az eszköz",
|
||||
"device_other": "Másik eszköz",
|
||||
"device_status_active": "Értesítéseket kézbesít",
|
||||
"device_status_inactive": "Még soha nem kézbesített értesítést",
|
||||
"device_status_unknown": "Ez az átjáró nem ismeri",
|
||||
"device_expires": "Lejár: {date}",
|
||||
"revoke": "Visszavonás",
|
||||
"confirm_revoke_title": "Visszavonja ezt az eszközt?",
|
||||
"confirm_revoke_message": "Azonnal nem kap több háttérértesítést. Ha az eszközön újra bekapcsolja a push értesítéseket, újra regisztrálódik.",
|
||||
"confirm_revoke_message_this": "Ez az eszköz azonnal nem kap több háttérértesítést."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "Értesítési hang",
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "In elaborazione…",
|
||||
"status_inactive": "Non abilitato su questo dispositivo",
|
||||
"status_unsupported": "Questo browser non supporta Web Push",
|
||||
"title": "Notifiche in background"
|
||||
"title": "Notifiche in background",
|
||||
"devices_title": "Dispositivi registrati",
|
||||
"devices_desc": "Tutti i dispositivi registrati per ricevere le notifiche in background di questo account. Revocandone uno, smette subito di riceverle.",
|
||||
"devices_loading": "Caricamento dei dispositivi registrati…",
|
||||
"devices_empty": "Nessun dispositivo è registrato per le notifiche in background.",
|
||||
"devices_error": "Impossibile caricare i dispositivi registrati.",
|
||||
"devices_refresh": "Aggiorna",
|
||||
"device_this": "Questo dispositivo",
|
||||
"device_other": "Altro dispositivo",
|
||||
"device_status_active": "Consegna le notifiche",
|
||||
"device_status_inactive": "Non ha mai consegnato una notifica",
|
||||
"device_status_unknown": "Sconosciuto a questo relay",
|
||||
"device_expires": "Scade il {date}",
|
||||
"revoke": "Revoca",
|
||||
"confirm_revoke_title": "Revocare questo dispositivo?",
|
||||
"confirm_revoke_message": "Smetterà subito di ricevere le notifiche in background. Riattivando il push su quel dispositivo verrà registrato di nuovo.",
|
||||
"confirm_revoke_message_this": "Questo dispositivo smetterà subito di ricevere le notifiche in background."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "処理中…",
|
||||
"status_inactive": "このデバイスでは有効になっていません",
|
||||
"status_unsupported": "このブラウザは Web Push をサポートしていません",
|
||||
"title": "バックグラウンド通知"
|
||||
"title": "バックグラウンド通知",
|
||||
"devices_title": "登録済みのデバイス",
|
||||
"devices_desc": "このアカウントのバックグラウンド通知を受け取るよう登録されているデバイスの一覧です。取り消すと、そのデバイスはすぐに受信を停止します。",
|
||||
"devices_loading": "登録済みのデバイスを読み込んでいます…",
|
||||
"devices_empty": "バックグラウンド通知に登録されたデバイスはありません。",
|
||||
"devices_error": "登録済みのデバイスを読み込めませんでした。",
|
||||
"devices_refresh": "更新",
|
||||
"device_this": "このデバイス",
|
||||
"device_other": "別のデバイス",
|
||||
"device_status_active": "通知を配信中",
|
||||
"device_status_inactive": "通知を配信したことがありません",
|
||||
"device_status_unknown": "このリレーでは不明",
|
||||
"device_expires": "有効期限 {date}",
|
||||
"revoke": "取り消す",
|
||||
"confirm_revoke_title": "このデバイスを取り消しますか?",
|
||||
"confirm_revoke_message": "すぐにバックグラウンド通知の受信を停止します。そのデバイスでプッシュを再度有効にすると、あらためて登録されます。",
|
||||
"confirm_revoke_message_this": "このデバイスはすぐにバックグラウンド通知の受信を停止します。"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "처리 중…",
|
||||
"status_inactive": "이 기기에서 활성화되지 않음",
|
||||
"status_unsupported": "이 브라우저는 Web Push를 지원하지 않습니다",
|
||||
"title": "백그라운드 알림"
|
||||
"title": "백그라운드 알림",
|
||||
"devices_title": "등록된 기기",
|
||||
"devices_desc": "이 계정의 백그라운드 알림을 받도록 등록된 모든 기기입니다. 해지하면 해당 기기는 즉시 수신을 멈추니다.",
|
||||
"devices_loading": "등록된 기기를 불러오는 중…",
|
||||
"devices_empty": "백그라운드 알림에 등록된 기기가 없습니다.",
|
||||
"devices_error": "등록된 기기를 불러오지 못했습니다.",
|
||||
"devices_refresh": "새로 고침",
|
||||
"device_this": "이 기기",
|
||||
"device_other": "다른 기기",
|
||||
"device_status_active": "알림 전달 중",
|
||||
"device_status_inactive": "알림을 전달한 적 없음",
|
||||
"device_status_unknown": "이 릴레이에서 알 수 없음",
|
||||
"device_expires": "{date} 만료",
|
||||
"revoke": "해지",
|
||||
"confirm_revoke_title": "이 기기를 해지할까요?",
|
||||
"confirm_revoke_message": "즉시 백그라운드 알림 수신을 멈추게 됩니다. 해당 기기에서 푸시를 다시 켜면 새로 등록됩니다.",
|
||||
"confirm_revoke_message_this": "이 기기는 즉시 백그라운드 알림 수신을 멈추게 됩니다."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Notiek darbs…",
|
||||
"status_inactive": "Nav iespējots šajā ierīcē",
|
||||
"status_unsupported": "Šī pārlūkprogramma neatbalsta Web Push",
|
||||
"title": "Fona paziņojumi"
|
||||
"title": "Fona paziņojumi",
|
||||
"devices_title": "Reģistrētās ierīces",
|
||||
"devices_desc": "Visas ierīces, kas reģistrētas šī konta fona paziņojumu saņemšanai. Atsaucot ierīci, tā nekavējoties pārstāj tās saņemt.",
|
||||
"devices_loading": "Notiek reģistrēto ierīču ielāde…",
|
||||
"devices_empty": "Fona paziņojumiem nav reģistrēta neviena ierīce.",
|
||||
"devices_error": "Neizdevās ielādēt reģistrētās ierīces.",
|
||||
"devices_refresh": "Atsvaidzināt",
|
||||
"device_this": "Šī ierīce",
|
||||
"device_other": "Cita ierīce",
|
||||
"device_status_active": "Piegādā paziņojumus",
|
||||
"device_status_inactive": "Nekad nav piegādājusi paziņojumu",
|
||||
"device_status_unknown": "Šim retranslatoram nezināma",
|
||||
"device_expires": "Beidzas {date}",
|
||||
"revoke": "Atsaukt",
|
||||
"confirm_revoke_title": "Atsaukt šo ierīci?",
|
||||
"confirm_revoke_message": "Tā nekavējoties pārstās saņemt fona paziņojumus. Atkārtoti ieslēdzot push šajā ierīcē, tā tiks reģistrēta no jauna.",
|
||||
"confirm_revoke_message_this": "Šī ierīce nekavējoties pārstās saņemt fona paziņojumus."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1090,7 +1090,23 @@
|
||||
"confirm_disable_title": "Арын мэдэгдлийг идэвхгүй болгох уу?",
|
||||
"confirm_disable_message": "Сайтыг хаах үед энэ төхөөрөмж дохио хүлээн авахаа болино.",
|
||||
"dismiss_aria": "Мэдэгдэл хүлээх мөрийг хаах",
|
||||
"ios_hint": "iOS дээр эхлээд сайтаа үндсэн дэлгэц дээрээ суулгаарай - Safari нь зөвхөн суулгасан PWA-д Web Push-г хүргэдэг."
|
||||
"ios_hint": "iOS дээр эхлээд сайтаа үндсэн дэлгэц дээрээ суулгаарай - Safari нь зөвхөн суулгасан PWA-д Web Push-г хүргэдэг.",
|
||||
"devices_title": "Бүртгэлтэй төхөөрөмжүүд",
|
||||
"devices_desc": "Энэ бүртгэлийн араарх мэдэгдлийг хүлээн авахаар бүртгэгдсэн бүх төхөөрөмж. Цуцлахад тухайн төхөөрөмж шууд хүлээн авахаа болино.",
|
||||
"devices_loading": "Бүртгэлтэй төхөөрөмжүүдийг ачаалж байна…",
|
||||
"devices_empty": "Араарх мэдэгдэлд бүртгэгдсэн төхөөрөмж алга.",
|
||||
"devices_error": "Бүртгэлтэй төхөөрөмжүүдийг ачаалж чадсангүй.",
|
||||
"devices_refresh": "Шинэчлэх",
|
||||
"device_this": "Энэ төхөөрөмж",
|
||||
"device_other": "Өөр төхөөрөмж",
|
||||
"device_status_active": "Мэдэгдэл хүргэж байна",
|
||||
"device_status_inactive": "Хэзээ ч мэдэгдэл хүргээгүй",
|
||||
"device_status_unknown": "Энэ реле танихгүй",
|
||||
"device_expires": "{date}-нд дуусна",
|
||||
"revoke": "Цуцлах",
|
||||
"confirm_revoke_title": "Энэ төхөөрөмжийг цуцлах уу?",
|
||||
"confirm_revoke_message": "Тэр даруй араарх мэдэгдэл хүлээн авахаа болино. Тухайн төхөөрөмж дээр push-ыг дахин асаавал дахин бүртгэгднэ.",
|
||||
"confirm_revoke_message_this": "Энэ төхөөрөмж тэр даруй араарх мэдэгдэл хүлээн авахаа болино."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "Мэдэгдэлийн дуу",
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Bezig…",
|
||||
"status_inactive": "Niet ingeschakeld op dit apparaat",
|
||||
"status_unsupported": "Deze browser ondersteunt geen Web Push",
|
||||
"title": "Achtergrondmeldingen"
|
||||
"title": "Achtergrondmeldingen",
|
||||
"devices_title": "Geregistreerde apparaten",
|
||||
"devices_desc": "Alle apparaten die achtergrondmeldingen voor dit account ontvangen. Trek je er een in, dan stopt de ontvangst meteen.",
|
||||
"devices_loading": "Geregistreerde apparaten laden…",
|
||||
"devices_empty": "Er zijn geen apparaten geregistreerd voor achtergrondmeldingen.",
|
||||
"devices_error": "Kan de geregistreerde apparaten niet laden.",
|
||||
"devices_refresh": "Vernieuwen",
|
||||
"device_this": "Dit apparaat",
|
||||
"device_other": "Ander apparaat",
|
||||
"device_status_active": "Levert meldingen af",
|
||||
"device_status_inactive": "Heeft nooit een melding afgeleverd",
|
||||
"device_status_unknown": "Niet bekend bij deze relay",
|
||||
"device_expires": "Verloopt op {date}",
|
||||
"revoke": "Intrekken",
|
||||
"confirm_revoke_title": "Dit apparaat intrekken?",
|
||||
"confirm_revoke_message": "Het ontvangt meteen geen achtergrondmeldingen meer. Push opnieuw inschakelen op dat apparaat registreert het opnieuw.",
|
||||
"confirm_revoke_message_this": "Dit apparaat ontvangt meteen geen achtergrondmeldingen meer."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Przetwarzanie…",
|
||||
"status_inactive": "Niewłączone na tym urządzeniu",
|
||||
"status_unsupported": "Ta przeglądarka nie obsługuje Web Push",
|
||||
"title": "Powiadomienia w tle"
|
||||
"title": "Powiadomienia w tle",
|
||||
"devices_title": "Zarejestrowane urządzenia",
|
||||
"devices_desc": "Wszystkie urządzenia zarejestrowane do odbioru powiadomień w tle dla tego konta. Unieważnienie natychmiast wstrzymuje ich odbiór.",
|
||||
"devices_loading": "Wczytywanie zarejestrowanych urządzeń…",
|
||||
"devices_empty": "Żadne urządzenie nie jest zarejestrowane do powiadomień w tle.",
|
||||
"devices_error": "Nie udało się wczytać zarejestrowanych urządzeń.",
|
||||
"devices_refresh": "Odśwież",
|
||||
"device_this": "To urządzenie",
|
||||
"device_other": "Inne urządzenie",
|
||||
"device_status_active": "Dostarcza powiadomienia",
|
||||
"device_status_inactive": "Nigdy nie dostarczyło powiadomienia",
|
||||
"device_status_unknown": "Nieznane temu przekaźnikowi",
|
||||
"device_expires": "Wygasa {date}",
|
||||
"revoke": "Unieważnij",
|
||||
"confirm_revoke_title": "Unieważnić to urządzenie?",
|
||||
"confirm_revoke_message": "Natychmiast przestanie odbierać powiadomienia w tle. Ponowne włączenie push na tym urządzeniu zarejestruje je od nowa.",
|
||||
"confirm_revoke_message_this": "To urządzenie natychmiast przestanie odbierać powiadomienia w tle."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Processando…",
|
||||
"status_inactive": "Não ativado neste dispositivo",
|
||||
"status_unsupported": "Este navegador não oferece suporte a Web Push",
|
||||
"title": "Notificações em segundo plano"
|
||||
"title": "Notificações em segundo plano",
|
||||
"devices_title": "Dispositivos registrados",
|
||||
"devices_desc": "Todos os dispositivos registrados para receber notificações em segundo plano desta conta. Revogar um deles interrompe o recebimento imediatamente.",
|
||||
"devices_loading": "Carregando dispositivos registrados…",
|
||||
"devices_empty": "Nenhum dispositivo está registrado para notificações em segundo plano.",
|
||||
"devices_error": "Não foi possível carregar os dispositivos registrados.",
|
||||
"devices_refresh": "Atualizar",
|
||||
"device_this": "Este dispositivo",
|
||||
"device_other": "Outro dispositivo",
|
||||
"device_status_active": "Entregando notificações",
|
||||
"device_status_inactive": "Nunca entregou uma notificação",
|
||||
"device_status_unknown": "Desconhecido para este relay",
|
||||
"device_expires": "Expira em {date}",
|
||||
"revoke": "Revogar",
|
||||
"confirm_revoke_title": "Revogar este dispositivo?",
|
||||
"confirm_revoke_message": "Ele deixará de receber notificações em segundo plano imediatamente. Ativar o push novamente nesse dispositivo o registrará de novo.",
|
||||
"confirm_revoke_message_this": "Este dispositivo deixará de receber notificações em segundo plano imediatamente."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1090,7 +1090,23 @@
|
||||
"confirm_disable_title": "Dezactivați notificările din fundal?",
|
||||
"confirm_disable_message": "Acest dispozitiv nu va mai primi alerte atunci când site-ul este închis.",
|
||||
"dismiss_aria": "Ignorați solicitarea de notificări",
|
||||
"ios_hint": "Pe iOS, instalați mai întâi site-ul pe ecranul de start – Safari trimite notificări Web Push numai către aplicațiile web progresive (PWA) instalate."
|
||||
"ios_hint": "Pe iOS, instalați mai întâi site-ul pe ecranul de start – Safari trimite notificări Web Push numai către aplicațiile web progresive (PWA) instalate.",
|
||||
"devices_title": "Dispozitive înregistrate",
|
||||
"devices_desc": "Toate dispozitivele înregistrate să primească notificări în fundal pentru acest cont. Revocarea unuia îl oprește imediat din a le primi.",
|
||||
"devices_loading": "Se încarcă dispozitivele înregistrate…",
|
||||
"devices_empty": "Niciun dispozitiv nu este înregistrat pentru notificări în fundal.",
|
||||
"devices_error": "Dispozitivele înregistrate nu au putut fi încărcate.",
|
||||
"devices_refresh": "Reîmprospătează",
|
||||
"device_this": "Acest dispozitiv",
|
||||
"device_other": "Alt dispozitiv",
|
||||
"device_status_active": "Livrează notificări",
|
||||
"device_status_inactive": "Nu a livrat niciodată o notificare",
|
||||
"device_status_unknown": "Necunoscut acestui releu",
|
||||
"device_expires": "Expiră la {date}",
|
||||
"revoke": "Revocă",
|
||||
"confirm_revoke_title": "Revocați acest dispozitiv?",
|
||||
"confirm_revoke_message": "Va înceta imediat să primească notificări în fundal. Reactivarea push pe acel dispozitiv îl va înregistra din nou.",
|
||||
"confirm_revoke_message_this": "Acest dispozitiv va înceta imediat să primească notificări în fundal."
|
||||
},
|
||||
"sound_selection": {
|
||||
"title": "Sunet de notificare",
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Выполняется…",
|
||||
"status_inactive": "Не включено на этом устройстве",
|
||||
"status_unsupported": "Этот браузер не поддерживает Web Push",
|
||||
"title": "Фоновые уведомления"
|
||||
"title": "Фоновые уведомления",
|
||||
"devices_title": "Зарегистрированные устройства",
|
||||
"devices_desc": "Все устройства, зарегистрированные для получения фоновых уведомлений этой учётной записи. После отзыва устройство сразу перестаёт их получать.",
|
||||
"devices_loading": "Загрузка зарегистрированных устройств…",
|
||||
"devices_empty": "Для фоновых уведомлений не зарегистрировано ни одного устройства.",
|
||||
"devices_error": "Не удалось загрузить зарегистрированные устройства.",
|
||||
"devices_refresh": "Обновить",
|
||||
"device_this": "Это устройство",
|
||||
"device_other": "Другое устройство",
|
||||
"device_status_active": "Доставляет уведомления",
|
||||
"device_status_inactive": "Ни разу не доставляло уведомлений",
|
||||
"device_status_unknown": "Неизвестно этому ретранслятору",
|
||||
"device_expires": "Истекает {date}",
|
||||
"revoke": "Отозвать",
|
||||
"confirm_revoke_title": "Отозвать это устройство?",
|
||||
"confirm_revoke_message": "Оно сразу перестанет получать фоновые уведомления. Если снова включить push на этом устройстве, оно зарегистрируется заново.",
|
||||
"confirm_revoke_message_this": "Это устройство сразу перестанет получать фоновые уведомления."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1114,7 +1114,23 @@
|
||||
"confirm_disable_title": "Zakázať oznámenia na pozadí?",
|
||||
"confirm_disable_message": "Toto zariadenie prestane prijímať upozornenia, keď je stránka zatvorená.",
|
||||
"dismiss_aria": "Zavrieť výzvu na oznámenia",
|
||||
"ios_hint": "Na iOS najprv nainštalujte stránku na domovskú obrazovku – Safari doručuje Web Push iba nainštalovaným PWA."
|
||||
"ios_hint": "Na iOS najprv nainštalujte stránku na domovskú obrazovku – Safari doručuje Web Push iba nainštalovaným PWA.",
|
||||
"devices_title": "Registrované zariadenia",
|
||||
"devices_desc": "Všetky zariadenia registrované na príjem oznámení na pozadí pre tento účet. Odvolanie okamžite zastaví ich príjem.",
|
||||
"devices_loading": "Načítavajú sa registrované zariadenia…",
|
||||
"devices_empty": "Pre oznámenia na pozadí nie je registrované žiadne zariadenie.",
|
||||
"devices_error": "Registrované zariadenia sa nepodarilo načítať.",
|
||||
"devices_refresh": "Obnoviť",
|
||||
"device_this": "Toto zariadenie",
|
||||
"device_other": "Iné zariadenie",
|
||||
"device_status_active": "Doručuje oznámenia",
|
||||
"device_status_inactive": "Nikdy nedoručilo oznámenie",
|
||||
"device_status_unknown": "Tomuto relay nie je známe",
|
||||
"device_expires": "Vyprší {date}",
|
||||
"revoke": "Odvolať",
|
||||
"confirm_revoke_title": "Odvolať toto zariadenie?",
|
||||
"confirm_revoke_message": "Okamžite prestane prijímať oznámenia na pozadí. Opätovné zapnutie push na tom zariadení ho zaregistruje nanovo.",
|
||||
"confirm_revoke_message_this": "Toto zariadenie okamžite prestane prijímať oznámenia na pozadí."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Çalışıyor…",
|
||||
"status_inactive": "Bu cihazda etkinleştirilmedi",
|
||||
"status_unsupported": "Bu tarayıcı Web Push'u desteklemiyor",
|
||||
"title": "Arka plan bildirimleri"
|
||||
"title": "Arka plan bildirimleri",
|
||||
"devices_title": "Kayıtlı cihazlar",
|
||||
"devices_desc": "Bu hesap için arka plan bildirimleri almak üzere kayıtlı tüm cihazlar. Birini iptal ettiğinizde o cihaz bildirimleri hemen almayı bırakır.",
|
||||
"devices_loading": "Kayıtlı cihazlar yükleniyor…",
|
||||
"devices_empty": "Arka plan bildirimleri için kayıtlı cihaz yok.",
|
||||
"devices_error": "Kayıtlı cihazlar yüklenemedi.",
|
||||
"devices_refresh": "Yenile",
|
||||
"device_this": "Bu cihaz",
|
||||
"device_other": "Diğer cihaz",
|
||||
"device_status_active": "Bildirim iletiyor",
|
||||
"device_status_inactive": "Hiç bildirim iletmedi",
|
||||
"device_status_unknown": "Bu aktarıcı tanımıyor",
|
||||
"device_expires": "Bitiş: {date}",
|
||||
"revoke": "İptal et",
|
||||
"confirm_revoke_title": "Bu cihaz iptal edilsin mi?",
|
||||
"confirm_revoke_message": "Arka plan bildirimlerini almayı hemen bırakacak. O cihazda push’u yeniden açarsanız yeniden kaydedilir.",
|
||||
"confirm_revoke_message_this": "Bu cihaz arka plan bildirimlerini almayı hemen bırakacak."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "Виконується…",
|
||||
"status_inactive": "Не ввімкнено на цьому пристрої",
|
||||
"status_unsupported": "Цей браузер не підтримує Web Push",
|
||||
"title": "Фонові сповіщення"
|
||||
"title": "Фонові сповіщення",
|
||||
"devices_title": "Зареєстровані пристрої",
|
||||
"devices_desc": "Усі пристрої, зареєстровані для отримання фонових сповіщень цього облікового запису. Після відкликання пристрій одразу перестає їх отримувати.",
|
||||
"devices_loading": "Завантаження зареєстрованих пристроїв…",
|
||||
"devices_empty": "Для фонових сповіщень не зареєстровано жодного пристрою.",
|
||||
"devices_error": "Не вдалося завантажити зареєстровані пристрої.",
|
||||
"devices_refresh": "Оновити",
|
||||
"device_this": "Цей пристрій",
|
||||
"device_other": "Інший пристрій",
|
||||
"device_status_active": "Доставляє сповіщення",
|
||||
"device_status_inactive": "Жодного разу не доставляв сповіщень",
|
||||
"device_status_unknown": "Невідомий цьому ретранслятору",
|
||||
"device_expires": "Спливає {date}",
|
||||
"revoke": "Відкликати",
|
||||
"confirm_revoke_title": "Відкликати цей пристрій?",
|
||||
"confirm_revoke_message": "Він одразу перестане отримувати фонові сповіщення. Якщо знову увімкнути push на цьому пристрої, він зареєструється заново.",
|
||||
"confirm_revoke_message_this": "Цей пристрій одразу перестане отримувати фонові сповіщення."
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
+17
-1
@@ -1111,7 +1111,23 @@
|
||||
"status_busy": "工作中…",
|
||||
"status_inactive": "在此设备上未启用",
|
||||
"status_unsupported": "此浏览器不支持 Web Push",
|
||||
"title": "后台通知"
|
||||
"title": "后台通知",
|
||||
"devices_title": "已注册的设备",
|
||||
"devices_desc": "已注册接收此账户后台通知的所有设备。撤销后,该设备会立即停止接收。",
|
||||
"devices_loading": "正在加载已注册的设备…",
|
||||
"devices_empty": "没有设备注册接收后台通知。",
|
||||
"devices_error": "无法加载已注册的设备。",
|
||||
"devices_refresh": "刷新",
|
||||
"device_this": "此设备",
|
||||
"device_other": "其他设备",
|
||||
"device_status_active": "正在投递通知",
|
||||
"device_status_inactive": "从未投递过通知",
|
||||
"device_status_unknown": "此中继不识别",
|
||||
"device_expires": "{date} 过期",
|
||||
"revoke": "撤销",
|
||||
"confirm_revoke_title": "要撤销此设备吗?",
|
||||
"confirm_revoke_message": "它将立即停止接收后台通知。在该设备上重新启用推送会重新注册。",
|
||||
"confirm_revoke_message_this": "此设备将立即停止接收后台通知。"
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
|
||||
Reference in New Issue
Block a user