mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: mute notifications for single folders or whole accounts #1169
This commit is contained in:
@@ -23,7 +23,12 @@ const mocks = vi.hoisted(() => ({
|
||||
getAllConnectedClients: () => new Map(mocks.authState.connected),
|
||||
},
|
||||
accounts: [] as { id: string; username: string }[],
|
||||
settingsState: { emailNotificationsEnabled: true, pushRelayUrl: "" },
|
||||
settingsState: {
|
||||
emailNotificationsEnabled: true,
|
||||
pushRelayUrl: "",
|
||||
pushNotifyInboxOnly: false,
|
||||
notificationMutes: { accounts: [] as string[], mailboxes: {} as Record<string, boolean> },
|
||||
},
|
||||
policyState: {
|
||||
loaded: true,
|
||||
policy: {
|
||||
@@ -51,9 +56,11 @@ vi.mock("@/stores/account-store", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/settings-store", () => ({
|
||||
useSettingsStore: (
|
||||
selector: (state: typeof mocks.settingsState) => unknown,
|
||||
) => selector(mocks.settingsState),
|
||||
useSettingsStore: Object.assign(
|
||||
(selector: (state: typeof mocks.settingsState) => unknown) =>
|
||||
selector(mocks.settingsState),
|
||||
{ getState: () => mocks.settingsState },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/stores/policy-store", () => ({
|
||||
@@ -102,6 +109,7 @@ describe("PushNotificationPrompt", () => {
|
||||
mocks.authState.isDemoMode = false;
|
||||
mocks.settingsState.emailNotificationsEnabled = true;
|
||||
mocks.settingsState.pushRelayUrl = "";
|
||||
mocks.settingsState.notificationMutes = { accounts: [], mailboxes: {} };
|
||||
mocks.policyState.loaded = true;
|
||||
mocks.policyState.policy.pushRelayUrl =
|
||||
"https://notifications.relay.bulwarkmail.org";
|
||||
@@ -139,6 +147,8 @@ describe("PushNotificationPrompt", () => {
|
||||
client: mocks.authState.client,
|
||||
relayBaseUrl: "https://notifications.relay.bulwarkmail.org",
|
||||
accountLabel: "alice@example.com",
|
||||
inboxOnly: false,
|
||||
mutes: { accounts: [], mailboxes: {} },
|
||||
});
|
||||
expect(screen.queryByText("title")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -260,6 +270,31 @@ describe("PushNotificationPrompt", () => {
|
||||
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
|
||||
});
|
||||
|
||||
it("sends a changed mute to every login right away, past the daily limit", async () => {
|
||||
const bob = { getAccountId: () => "account-2" };
|
||||
mocks.authState.connected.set("bob@mail.example.com", bob);
|
||||
const { rerender } = render(<PushNotificationPrompt />);
|
||||
await advancePromptDelay();
|
||||
mocks.resyncWebPush.mockClear();
|
||||
|
||||
// An equal object from a settings sync is no change.
|
||||
mocks.settingsState.notificationMutes = { accounts: [], mailboxes: {} };
|
||||
rerender(<PushNotificationPrompt />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||
expect(mocks.resyncWebPush).not.toHaveBeenCalled();
|
||||
|
||||
const muted = { accounts: ["account-2"], mailboxes: {} };
|
||||
mocks.settingsState.notificationMutes = muted;
|
||||
rerender(<PushNotificationPrompt />);
|
||||
await act(async () => { await vi.advanceTimersByTimeAsync(5_000); });
|
||||
|
||||
expect(mocks.resyncWebPush).toHaveBeenCalledTimes(2);
|
||||
for (const [params] of mocks.resyncWebPush.mock.calls) {
|
||||
expect(params).toMatchObject({ mutes: muted, force: true });
|
||||
}
|
||||
expect(mocks.resyncWebPush.mock.calls.map(([p]) => p.client)).toContain(bob);
|
||||
});
|
||||
|
||||
it("does not touch push in demo mode", async () => {
|
||||
mocks.authState.isDemoMode = true;
|
||||
render(<PushNotificationPrompt />);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { Mailbox } from '@/lib/jmap/types';
|
||||
|
||||
// #1169: folders and whole accounts can be muted from their context menu; a
|
||||
// folder's own setting wins over its account's.
|
||||
|
||||
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
|
||||
vi.mock('@/components/tour/tour-provider', () => ({
|
||||
useTour: () => ({ startTour: vi.fn(), resetTourCompletion: vi.fn(), activeTour: null, endTour: vi.fn(), registerStep: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/contexts/drag-drop-context', () => ({
|
||||
useDragDropContext: () => ({ isDragging: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-mailbox-drop', () => ({
|
||||
useMailboxDrop: () => ({ dropHandlers: {}, isValidDropTarget: false, isInvalidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('@/hooks/use-tag-drop', () => ({
|
||||
useTagDrop: () => ({ dropHandlers: {}, isValidDropTarget: false }),
|
||||
}));
|
||||
vi.mock('../account-switcher', () => ({ AccountSwitcher: () => null }));
|
||||
|
||||
const { Sidebar } = await import('../sidebar');
|
||||
const { useAuthStore } = await import('@/stores/auth-store');
|
||||
const { useSettingsStore } = await import('@/stores/settings-store');
|
||||
|
||||
const box = (id: string, name: string, role: string | null, extra: Partial<Mailbox> = {}) => ({
|
||||
id, name, role, parentId: null, totalEmails: 0, unreadEmails: 0, myRights: { mayAddItems: true }, ...extra,
|
||||
}) as unknown as Mailbox;
|
||||
|
||||
const mailboxes: Mailbox[] = [
|
||||
box('inbox', 'Inbox', 'inbox'),
|
||||
box('renovate', 'Renovate', null),
|
||||
box('g1:g1-inbox', 'Inbox', 'inbox', { originalId: 'g1-inbox', accountId: 'g1', accountName: 'Postmaster', isShared: true }),
|
||||
];
|
||||
|
||||
function client(emailPush: boolean) {
|
||||
return {
|
||||
getAccountId: () => 'me',
|
||||
getCapabilities: () => (emailPush ? { 'urn:ietf:params:jmap:emailpush': {} } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const row = (mailboxId: string) =>
|
||||
screen.getAllByTestId('folder-row').find((el) => el.getAttribute('data-mailbox-id') === mailboxId)!;
|
||||
const mutes = () => useSettingsStore.getState().notificationMutes;
|
||||
|
||||
describe('Sidebar notification mutes (#1169)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem('sidebarSharedExpanded', 'true');
|
||||
localStorage.setItem('sidebarExpandedSharedAccounts', JSON.stringify(['shared-account-g1']));
|
||||
useAuthStore.setState({ activeAccountId: 'login-a', client: client(true) } as never);
|
||||
useSettingsStore.setState({
|
||||
hideAccountSwitcher: true,
|
||||
showSharedMailAccounts: true,
|
||||
hiddenSharedMailAccounts: {},
|
||||
notificationMutes: { accounts: [], mailboxes: {} },
|
||||
} as never);
|
||||
});
|
||||
|
||||
it('mutes and unmutes one folder from its context menu', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
expect(row('renovate').querySelector('[data-testid="folder-muted"]')).toBeNull();
|
||||
|
||||
fireEvent.contextMenu(row('renovate'));
|
||||
fireEvent.click(screen.getByTestId('mailbox-mute'));
|
||||
|
||||
expect(mutes().mailboxes).toEqual({ 'me/renovate': true });
|
||||
expect(row('renovate').querySelector('[data-testid="folder-muted"]')).not.toBeNull();
|
||||
expect(row('inbox').querySelector('[data-testid="folder-muted"]')).toBeNull();
|
||||
|
||||
fireEvent.contextMenu(row('renovate'));
|
||||
fireEvent.click(screen.getByTestId('mailbox-mute'));
|
||||
expect(mutes().mailboxes).toEqual({});
|
||||
});
|
||||
|
||||
it('mutes a shared account, unmutes one of its folders and resets that again', () => {
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
|
||||
fireEvent.contextMenu(screen.getAllByTestId('section-shared-account')[0]);
|
||||
expect(screen.queryByTestId('mailbox-reset-folder-mutes')).toBeNull();
|
||||
fireEvent.click(screen.getByTestId('mailbox-mute-account'));
|
||||
|
||||
expect(mutes().accounts).toEqual(['g1']);
|
||||
expect(row('g1:g1-inbox').querySelector('[data-testid="folder-muted"]')).not.toBeNull();
|
||||
// The own account is not affected.
|
||||
expect(row('inbox').querySelector('[data-testid="folder-muted"]')).toBeNull();
|
||||
|
||||
// The folder's own setting wins over the muted account, keyed by the server id.
|
||||
fireEvent.contextMenu(row('g1:g1-inbox'));
|
||||
fireEvent.click(screen.getByTestId('mailbox-mute'));
|
||||
expect(mutes().mailboxes).toEqual({ 'g1/g1-inbox': false });
|
||||
expect(row('g1:g1-inbox').querySelector('[data-testid="folder-muted"]')).toBeNull();
|
||||
|
||||
fireEvent.contextMenu(screen.getAllByTestId('section-shared-account')[0]);
|
||||
fireEvent.click(screen.getByTestId('mailbox-reset-folder-mutes'));
|
||||
expect(mutes().mailboxes).toEqual({});
|
||||
expect(row('g1:g1-inbox').querySelector('[data-testid="folder-muted"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('offers no mute without the server push filter', () => {
|
||||
useAuthStore.setState({ client: client(false) } as never);
|
||||
render(<Sidebar mailboxes={mailboxes} selectedMailbox="inbox" />);
|
||||
|
||||
fireEvent.contextMenu(row('renovate'));
|
||||
expect(screen.queryByTestId('mailbox-mute')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
Upload,
|
||||
Users,
|
||||
EyeOff,
|
||||
Bell,
|
||||
BellOff,
|
||||
RotateCcw,
|
||||
} from "@/components/icons";
|
||||
import { FolderRulesSubMenu } from "@/components/email/rules-menu";
|
||||
|
||||
@@ -73,6 +76,20 @@ function renderShortenedPath(fullPath: string): React.ReactNode {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-account and per-folder notification mutes (#1169). An `accountId` of
|
||||
* undefined is the login's own account, as on the folders-section target.
|
||||
*/
|
||||
export interface MailboxNotificationActions {
|
||||
isMailboxMuted: (mailbox: Mailbox) => boolean;
|
||||
isAccountMuted: (accountId?: string) => boolean;
|
||||
/** True when some folder of the account has its own setting. */
|
||||
hasFolderSettings: (accountId?: string) => boolean;
|
||||
setMailboxMuted: (mailbox: Mailbox, muted: boolean) => void;
|
||||
setAccountMuted: (accountId: string | undefined, muted: boolean) => void;
|
||||
resetFolderSettings: (accountId?: string) => void;
|
||||
}
|
||||
|
||||
interface MailboxContextMenuProps {
|
||||
target: MailboxContextTarget | null;
|
||||
position: Position;
|
||||
@@ -94,6 +111,8 @@ interface MailboxContextMenuProps {
|
||||
onRefresh?: () => void;
|
||||
/** Hide a shared account's mail folders from the sidebar (#1228). */
|
||||
onHideSharedAccount?: (accountId: string) => void;
|
||||
/** Omitted when muting has no effect (no emailPush filter on the server). */
|
||||
notifications?: MailboxNotificationActions;
|
||||
}
|
||||
|
||||
export function MailboxContextMenu({
|
||||
@@ -115,6 +134,7 @@ export function MailboxContextMenu({
|
||||
onImportEmail,
|
||||
onRefresh,
|
||||
onHideSharedAccount,
|
||||
notifications,
|
||||
}: MailboxContextMenuProps) {
|
||||
const t = useTranslations("mailbox_context_menu");
|
||||
|
||||
@@ -152,6 +172,28 @@ export function MailboxContextMenu({
|
||||
onClick={() => handleAction(onRefresh!)}
|
||||
disabled={!onRefresh}
|
||||
/>
|
||||
{notifications && (() => {
|
||||
const accountMuted = notifications.isAccountMuted(target.accountId);
|
||||
return (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={accountMuted ? Bell : BellOff}
|
||||
label={accountMuted ? t("unmute_account_notifications") : t("mute_account_notifications")}
|
||||
onClick={() => handleAction(() => notifications.setAccountMuted(target.accountId, !accountMuted))}
|
||||
testId="mailbox-mute-account"
|
||||
/>
|
||||
{notifications.hasFolderSettings(target.accountId) && (
|
||||
<ContextMenuItem
|
||||
icon={RotateCcw}
|
||||
label={t("reset_folder_notifications")}
|
||||
onClick={() => handleAction(() => notifications.resetFolderSettings(target.accountId))}
|
||||
testId="mailbox-reset-folder-mutes"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
{target.accountId && onHideSharedAccount && (
|
||||
<>
|
||||
<ContextMenuSeparator />
|
||||
@@ -231,6 +273,17 @@ export function MailboxContextMenu({
|
||||
/>
|
||||
)}
|
||||
<FolderRulesSubMenu mailbox={mailbox} onClose={onClose} />
|
||||
{notifications && (() => {
|
||||
const muted = notifications.isMailboxMuted(mailbox);
|
||||
return (
|
||||
<ContextMenuItem
|
||||
icon={muted ? Bell : BellOff}
|
||||
label={muted ? t("unmute_notifications") : t("mute_notifications")}
|
||||
onClick={() => handleAction(() => notifications.setMailboxMuted(mailbox, !muted))}
|
||||
testId="mailbox-mute"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
|
||||
<ContextMenuSeparator />
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ import { useKeywordFormat } from "@/hooks/use-keyword-format";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
import { Mailbox } from "@/lib/jmap/types";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget } from "./mailbox-context-menu";
|
||||
import { MailboxContextMenu, type MailboxContextTarget, type MailboxNotificationActions } from "./mailbox-context-menu";
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { UNIFIED_MAILBOX_IDS, CROSS_VIEW_IDS } from '@/lib/jmap/types';
|
||||
@@ -78,6 +78,16 @@ import { buildSettingsPath, SCHEDULED_MAILBOX_ID, scopedScheduledMailboxId } fro
|
||||
import { useTour } from "@/components/tour/tour-provider";
|
||||
import { toUnicodeEmail } from "@/lib/idn";
|
||||
import { hiddenSharedMailIds, isSharedMailAccountVisible, setSharedMailAccountHidden } from "@/lib/shared-mail-visibility";
|
||||
import {
|
||||
isAccountMuted,
|
||||
isMailboxMuted,
|
||||
mailboxOverridesForAccount,
|
||||
resetMailboxMutes,
|
||||
setAccountMuted,
|
||||
setMailboxMuted,
|
||||
type NotificationMutes,
|
||||
} from "@/lib/notification-mutes";
|
||||
import { serverSupportsEmailPush } from "@/lib/web-push";
|
||||
|
||||
interface SidebarProps {
|
||||
mailboxes: Mailbox[];
|
||||
@@ -298,6 +308,8 @@ interface SidebarRowProps {
|
||||
isValidDropTarget?: boolean;
|
||||
isInvalidDropTarget?: boolean;
|
||||
onContextMenu?: (e: React.MouseEvent) => void;
|
||||
/** New mail here does not notify (#1169). */
|
||||
muted?: boolean;
|
||||
/** Stable identifiers for integration tests (not user-visible). */
|
||||
testRole?: string | null;
|
||||
testName?: string;
|
||||
@@ -324,6 +336,7 @@ function SidebarRow({
|
||||
isValidDropTarget,
|
||||
isInvalidDropTarget,
|
||||
onContextMenu,
|
||||
muted = false,
|
||||
testRole,
|
||||
testName,
|
||||
testMailboxId,
|
||||
@@ -402,6 +415,13 @@ function SidebarRow({
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
<span ref={labelRef} className="flex-1 truncate">{shortenedLabel}</span>
|
||||
{muted && (
|
||||
<BellOff
|
||||
className="w-3.5 h-3.5 flex-shrink-0 text-muted-foreground"
|
||||
aria-label={t('notifications_muted')}
|
||||
data-testid="folder-muted"
|
||||
/>
|
||||
)}
|
||||
<SidebarRowCounts
|
||||
unread={unread}
|
||||
total={total}
|
||||
@@ -502,6 +522,15 @@ function SidebarSectionHeader({
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether new mail in this folder of the active login stays silent (#1169). */
|
||||
function useMutedMailbox(node: MailboxNode | null): boolean {
|
||||
const client = useAuthStore((s) => s.client);
|
||||
return useSettingsStore((s) => {
|
||||
if (!node || !client) return false;
|
||||
return isMailboxMuted(s.notificationMutes, node.accountId || client.getAccountId(), node.originalId || node.id);
|
||||
});
|
||||
}
|
||||
|
||||
function MailboxTreeItem({
|
||||
node,
|
||||
selectedMailbox,
|
||||
@@ -536,6 +565,9 @@ function MailboxTreeItem({
|
||||
const isVirtualNode = node.id.startsWith('shared-');
|
||||
const isSelected = selectedMailbox === node.id;
|
||||
const label = localizeMailboxName(node.role, node.name, (k) => tSidebar(`mailboxes.${k}`));
|
||||
// Only for the active login's folders: its client tells a missing
|
||||
// accountId (own mailbox) apart.
|
||||
const muted = useMutedMailbox(isVirtualNode || dragAccountId ? null : node);
|
||||
|
||||
const isEmbedded = useIsEmbedded();
|
||||
// Pro shell only: folder rows are drag sources so a folder can be dropped
|
||||
@@ -607,6 +639,7 @@ function MailboxTreeItem({
|
||||
isValidDropTarget={isValidDropTarget}
|
||||
isInvalidDropTarget={isInvalidDropTarget}
|
||||
onContextMenu={onContextMenu && !isVirtualNode ? (e) => onContextMenu(e, node) : undefined}
|
||||
muted={muted}
|
||||
/>
|
||||
|
||||
{hasChildren && isExpanded && !isCollapsed && node.children.map((child) => (
|
||||
@@ -1277,6 +1310,28 @@ function SidebarImpl({
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Muting only takes effect through the server's emailPush filter, so the
|
||||
// menu offers it only where that exists, like the "Inbox only" setting.
|
||||
const authClient = useAuthStore((s) => s.client);
|
||||
const notificationMutes = useSettingsStore((s) => s.notificationMutes);
|
||||
const notificationActions = useMemo<MailboxNotificationActions | undefined>(() => {
|
||||
if (!authClient || !serverSupportsEmailPush(authClient)) return undefined;
|
||||
const accountOf = (accountId?: string) => accountId || authClient.getAccountId();
|
||||
const mailboxRef = (mailbox: Mailbox) => [accountOf(mailbox.accountId), mailbox.originalId || mailbox.id] as const;
|
||||
const save = (next: NotificationMutes) => useSettingsStore.getState().updateSetting('notificationMutes', next);
|
||||
return {
|
||||
isMailboxMuted: (mailbox) => isMailboxMuted(notificationMutes, ...mailboxRef(mailbox)),
|
||||
isAccountMuted: (accountId) => isAccountMuted(notificationMutes, accountOf(accountId)),
|
||||
hasFolderSettings: (accountId) => {
|
||||
const { muted, unmuted } = mailboxOverridesForAccount(notificationMutes, accountOf(accountId));
|
||||
return muted.length + unmuted.length > 0;
|
||||
},
|
||||
setMailboxMuted: (mailbox, muted) => save(setMailboxMuted(notificationMutes, ...mailboxRef(mailbox), muted)),
|
||||
setAccountMuted: (accountId, muted) => save(setAccountMuted(notificationMutes, accountOf(accountId), muted)),
|
||||
resetFolderSettings: (accountId) => save(resetMailboxMutes(notificationMutes, accountOf(accountId))),
|
||||
};
|
||||
}, [authClient, notificationMutes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={mvShell && sidebarCollapsed ? () => setRailHovered(true) : undefined}
|
||||
@@ -1718,6 +1773,7 @@ function SidebarImpl({
|
||||
onImportEmail={onImportEmail}
|
||||
onRefresh={onRefreshMailboxes}
|
||||
onHideSharedAccount={hideSharedAccount}
|
||||
notifications={notificationActions}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useLayoutEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Bell, Loader2, X } from "@/components/icons";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
|
||||
const DISMISSED_KEY_PREFIX = "bulwark.push.onboarding.dismissed.v1.";
|
||||
export const PUSH_NOTIFICATION_PROMPT_DELAY_MS = 1_000;
|
||||
export const MUTES_RESYNC_DELAY_MS = 1_000;
|
||||
|
||||
function dismissedKey(accountId: string): string {
|
||||
return `${DISMISSED_KEY_PREFIX}${accountId}`;
|
||||
@@ -169,6 +170,7 @@ export function PushNotificationPrompt() {
|
||||
relayBaseUrl,
|
||||
accountLabel: target.accountLabel,
|
||||
inboxOnly: pushNotifyInboxOnly,
|
||||
mutes: useSettingsStore.getState().notificationMutes,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -183,6 +185,39 @@ export function PushNotificationPrompt() {
|
||||
};
|
||||
}, [accountId, client, connectedAccountsRevision, isAuthenticated, isDemoMode, policyLoaded, pushNotifyInboxOnly, relayBaseUrl, username]);
|
||||
|
||||
// Muting a folder or account changes the server-side push filter, which the
|
||||
// daily renewal above would only send tomorrow. Send it to every login with
|
||||
// push on now, after a short pause so a few clicks in a row make one update.
|
||||
// Compared by content: a settings sync hands back an equal but new object.
|
||||
const notificationMutes = useSettingsStore((state) => state.notificationMutes);
|
||||
const mutesKey = JSON.stringify(notificationMutes);
|
||||
const lastMutesKey = useRef(mutesKey);
|
||||
useEffect(() => {
|
||||
if (lastMutesKey.current === mutesKey) return;
|
||||
lastMutesKey.current = mutesKey;
|
||||
if (!policyLoaded || !isAuthenticated || !client || isDemoMode) return;
|
||||
let cancelled = false;
|
||||
const timer = setTimeout(async () => {
|
||||
for (const target of resyncTargets(client, username ?? undefined)) {
|
||||
if (cancelled) return;
|
||||
await resyncWebPush({
|
||||
client: target.client,
|
||||
relayBaseUrl,
|
||||
accountLabel: target.accountLabel,
|
||||
inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly,
|
||||
mutes: useSettingsStore.getState().notificationMutes,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}, MUTES_RESYNC_DELAY_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
// Only a change of the mutes should send an update; the rest is read live.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mutesKey]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -244,6 +279,7 @@ export function PushNotificationPrompt() {
|
||||
relayBaseUrl,
|
||||
accountLabel: username ?? undefined,
|
||||
inboxOnly: pushNotifyInboxOnly,
|
||||
mutes: useSettingsStore.getState().notificationMutes,
|
||||
});
|
||||
setShowPrompt(false);
|
||||
} catch (err) {
|
||||
|
||||
@@ -76,7 +76,11 @@ export function MultiAccountPushSettings({ relayBaseUrl }: { relayBaseUrl?: stri
|
||||
if (action === 'enable') {
|
||||
const outcome = await enableWebPushForAccounts(
|
||||
targets.map(a => ({ accountId: a.id, client: auth.getClientForAccount(a.id)!, accountLabel: a.username })),
|
||||
{ relayBaseUrl, inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly },
|
||||
{
|
||||
relayBaseUrl,
|
||||
inboxOnly: useSettingsStore.getState().pushNotifyInboxOnly,
|
||||
mutes: useSettingsStore.getState().notificationMutes,
|
||||
},
|
||||
);
|
||||
setNotice(outcome.failed.length
|
||||
? t('result_partial', { enabled: outcome.enabled.length, failed: outcome.failed.length })
|
||||
|
||||
@@ -157,6 +157,7 @@ export function NotificationSettings() {
|
||||
accountLabel: username ?? undefined,
|
||||
forceRecreate,
|
||||
inboxOnly: pushNotifyInboxOnly,
|
||||
mutes: useSettingsStore.getState().notificationMutes,
|
||||
});
|
||||
setPushStatus({ kind: 'enabled' });
|
||||
} catch (err) {
|
||||
@@ -193,6 +194,7 @@ export function NotificationSettings() {
|
||||
relayBaseUrl: activeRelayUrl,
|
||||
accountLabel: username ?? undefined,
|
||||
inboxOnly: pushNotifyInboxOnly,
|
||||
mutes: useSettingsStore.getState().notificationMutes,
|
||||
})
|
||||
.then(() => { if (!cancelled) setPushStatus({ kind: 'enabled' }); })
|
||||
.catch((err) => {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
EMPTY_NOTIFICATION_MUTES,
|
||||
isMailboxMuted,
|
||||
mailboxOverridesForAccount,
|
||||
normalizeNotificationMutes,
|
||||
resetMailboxMutes,
|
||||
setAccountMuted,
|
||||
setMailboxMuted,
|
||||
} from '@/lib/notification-mutes';
|
||||
|
||||
describe('notification mutes (#1169)', () => {
|
||||
it('lets a folder follow its account until it has its own setting', () => {
|
||||
const accountMuted = setAccountMuted(EMPTY_NOTIFICATION_MUTES, 'a', true);
|
||||
expect(isMailboxMuted(accountMuted, 'a', 'reports')).toBe(true);
|
||||
expect(isMailboxMuted(accountMuted, 'b', 'reports')).toBe(false);
|
||||
|
||||
const unmutedFolder = setMailboxMuted(accountMuted, 'a', 'inbox', false);
|
||||
expect(isMailboxMuted(unmutedFolder, 'a', 'inbox')).toBe(false);
|
||||
expect(isMailboxMuted(unmutedFolder, 'a', 'reports')).toBe(true);
|
||||
|
||||
// The folder's own setting wins over the account in both directions.
|
||||
const accountBack = setAccountMuted(unmutedFolder, 'a', false);
|
||||
const mutedFolder = setMailboxMuted(accountBack, 'a', 'renovate', true);
|
||||
expect(isMailboxMuted(mutedFolder, 'a', 'renovate')).toBe(true);
|
||||
expect(isMailboxMuted(mutedFolder, 'a', 'reports')).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the folder setting when it is set back to what the account says', () => {
|
||||
const muted = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'renovate', true);
|
||||
const unmuted = setMailboxMuted(muted, 'a', 'renovate', false);
|
||||
expect(unmuted.mailboxes).toEqual({});
|
||||
|
||||
// So muting the account later reaches this folder too.
|
||||
expect(isMailboxMuted(setAccountMuted(unmuted, 'a', true), 'a', 'renovate')).toBe(true);
|
||||
});
|
||||
|
||||
it('resets the folder settings of one account only', () => {
|
||||
let mutes = setMailboxMuted(EMPTY_NOTIFICATION_MUTES, 'a', 'x', true);
|
||||
mutes = setMailboxMuted(mutes, 'ab', 'y', true);
|
||||
mutes = resetMailboxMutes(mutes, 'a');
|
||||
expect(mailboxOverridesForAccount(mutes, 'a')).toEqual({ muted: [], unmuted: [] });
|
||||
// An account id that merely starts with "a" keeps its settings.
|
||||
expect(mailboxOverridesForAccount(mutes, 'ab')).toEqual({ muted: ['y'], unmuted: [] });
|
||||
});
|
||||
|
||||
it('repairs malformed stored values', () => {
|
||||
expect(normalizeNotificationMutes(null)).toEqual(EMPTY_NOTIFICATION_MUTES);
|
||||
expect(normalizeNotificationMutes(['a'])).toEqual(EMPTY_NOTIFICATION_MUTES);
|
||||
expect(normalizeNotificationMutes({ accounts: ['a', 3, ''], mailboxes: { 'a/x': true, 'a/y': 'yes' } }))
|
||||
.toEqual({ accounts: ['a'], mailboxes: { 'a/x': true } });
|
||||
});
|
||||
});
|
||||
@@ -564,6 +564,84 @@ describe('buildEmailPushConfig inbox-only mode', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Muted accounts and folders (#1169) are left out by the server-side filter.
|
||||
describe('buildEmailPushConfig with muted folders and accounts', () => {
|
||||
const mutes = (accounts: string[], mailboxes: Record<string, boolean> = {}) => ({ accounts, mailboxes });
|
||||
|
||||
it('adds a muted folder to the excluded mailboxes next to Junk', async () => {
|
||||
const client = makeClient([], { emailPushCapability: true });
|
||||
|
||||
const config = await buildEmailPushConfig(client, false, mutes([], { [`${ACCOUNT_ID}/mb-archive`]: true }));
|
||||
|
||||
expect(config[ACCOUNT_ID].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { inMailboxOtherThan: [JUNK_ID, 'mb-archive'].sort() }],
|
||||
});
|
||||
// Another account's folders are untouched.
|
||||
expect(config[SHARED_ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[SHARED_ACCOUNT_ID]);
|
||||
});
|
||||
|
||||
it('silences a muted account entirely', async () => {
|
||||
const client = makeClient([], { emailPushCapability: true });
|
||||
|
||||
const config = await buildEmailPushConfig(client, false, mutes([SHARED_ACCOUNT_ID]));
|
||||
|
||||
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
|
||||
});
|
||||
expect(config[ACCOUNT_ID]).toEqual(EXPECTED_EMAIL_PUSH[ACCOUNT_ID]);
|
||||
});
|
||||
|
||||
it('lets a muted account notify for the folders unmuted again', async () => {
|
||||
const client = makeClient([], { emailPushCapability: true });
|
||||
|
||||
const one = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], { [`${ACCOUNT_ID}/mb-archive`]: false }));
|
||||
expect(one[ACCOUNT_ID].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-archive' }],
|
||||
});
|
||||
|
||||
const two = await buildEmailPushConfig(client, false, mutes([ACCOUNT_ID], {
|
||||
[`${ACCOUNT_ID}/mb-inbox`]: false,
|
||||
[`${ACCOUNT_ID}/mb-archive`]: false,
|
||||
// Gone from the server: must not end up in the filter.
|
||||
[`${ACCOUNT_ID}/mb-deleted`]: false,
|
||||
}));
|
||||
expect(two[ACCOUNT_ID].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [
|
||||
{ notKeyword: '$junk' },
|
||||
{ operator: 'OR', conditions: [{ inMailbox: 'mb-archive' }, { inMailbox: 'mb-inbox' }] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('silences the account in inbox-only mode when its Inbox is muted', async () => {
|
||||
const client = makeClient([], { emailPushCapability: true });
|
||||
|
||||
const config = await buildEmailPushConfig(client, true, mutes([], { [`${SHARED_ACCOUNT_ID}/mb-shared-inbox`]: true }));
|
||||
|
||||
expect(config[SHARED_ACCOUNT_ID].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { hasKeyword: '$junk' }],
|
||||
});
|
||||
expect(config[ACCOUNT_ID].filter).toEqual({
|
||||
operator: 'AND',
|
||||
conditions: [{ notKeyword: '$junk' }, { inMailbox: 'mb-inbox' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('fails instead of dropping the mutes when the mailboxes cannot be read', async () => {
|
||||
const client = makeClient([], { emailPushCapability: true });
|
||||
client.getAllMailboxes = vi.fn(async () => {
|
||||
throw new Error('JMAP down');
|
||||
}) as unknown as IJMAPClient['getAllMailboxes'];
|
||||
|
||||
await expect(buildEmailPushConfig(client, false, mutes([ACCOUNT_ID]))).rejects.toThrow(/JMAP down/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableWebPush', () => {
|
||||
it('destroys every subscription this device owns, not just the recorded id', async () => {
|
||||
localStorage.setItem(DEVICE_KEY, THIS_DEVICE);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Which accounts and folders stay silent when new mail arrives (#1169).
|
||||
*
|
||||
* An account is either muted or not. A folder can follow its account or carry
|
||||
* its own setting, and its own setting always wins: mute one folder in a
|
||||
* normal account, or mute a whole account and unmute the folders that still
|
||||
* matter. Ids are the server's JMAP account and mailbox ids, which is what the
|
||||
* push filter needs; shared-account mailboxes must pass their originalId.
|
||||
*/
|
||||
|
||||
export interface NotificationMutes {
|
||||
/** JMAP account ids muted as a whole. */
|
||||
accounts: string[];
|
||||
/** `accountId/mailboxId` -> true (muted) or false (unmuted). Missing = follow the account. */
|
||||
mailboxes: Record<string, boolean>;
|
||||
}
|
||||
|
||||
export const EMPTY_NOTIFICATION_MUTES: NotificationMutes = { accounts: [], mailboxes: {} };
|
||||
|
||||
function mailboxKey(accountId: string, mailboxId: string): string {
|
||||
return `${accountId}/${mailboxId}`;
|
||||
}
|
||||
|
||||
/** Repairs a value from storage or another build into a usable mutes object. */
|
||||
export function normalizeNotificationMutes(value: unknown): NotificationMutes {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) return EMPTY_NOTIFICATION_MUTES;
|
||||
const raw = value as { accounts?: unknown; mailboxes?: unknown };
|
||||
const accounts = Array.isArray(raw.accounts)
|
||||
? raw.accounts.filter((id): id is string => typeof id === 'string' && id !== '')
|
||||
: [];
|
||||
const mailboxes: Record<string, boolean> = {};
|
||||
if (raw.mailboxes && typeof raw.mailboxes === 'object' && !Array.isArray(raw.mailboxes)) {
|
||||
for (const [key, muted] of Object.entries(raw.mailboxes as Record<string, unknown>)) {
|
||||
if (typeof muted === 'boolean') mailboxes[key] = muted;
|
||||
}
|
||||
}
|
||||
return { accounts, mailboxes };
|
||||
}
|
||||
|
||||
export function isAccountMuted(mutes: NotificationMutes, accountId: string): boolean {
|
||||
return mutes.accounts.includes(accountId);
|
||||
}
|
||||
|
||||
/** The folder's own setting, or undefined when it follows its account. */
|
||||
export function mailboxMuteOverride(
|
||||
mutes: NotificationMutes,
|
||||
accountId: string,
|
||||
mailboxId: string,
|
||||
): boolean | undefined {
|
||||
return mutes.mailboxes[mailboxKey(accountId, mailboxId)];
|
||||
}
|
||||
|
||||
export function isMailboxMuted(mutes: NotificationMutes, accountId: string, mailboxId: string): boolean {
|
||||
return mailboxMuteOverride(mutes, accountId, mailboxId) ?? isAccountMuted(mutes, accountId);
|
||||
}
|
||||
|
||||
/** Mailbox ids of one account with their own setting, split by that setting. */
|
||||
export function mailboxOverridesForAccount(
|
||||
mutes: NotificationMutes,
|
||||
accountId: string,
|
||||
): { muted: string[]; unmuted: string[] } {
|
||||
const prefix = mailboxKey(accountId, '');
|
||||
const muted: string[] = [];
|
||||
const unmuted: string[] = [];
|
||||
for (const [key, value] of Object.entries(mutes.mailboxes)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
(value ? muted : unmuted).push(key.slice(prefix.length));
|
||||
}
|
||||
return { muted, unmuted };
|
||||
}
|
||||
|
||||
export function setAccountMuted(mutes: NotificationMutes, accountId: string, muted: boolean): NotificationMutes {
|
||||
const accounts = mutes.accounts.filter((id) => id !== accountId);
|
||||
if (muted) accounts.push(accountId);
|
||||
return { ...mutes, accounts };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutes or unmutes one folder. Setting it back to what the account says drops
|
||||
* the folder's own setting, so a folder muted and unmuted again follows the
|
||||
* account later instead of staying pinned to "unmuted".
|
||||
*/
|
||||
export function setMailboxMuted(
|
||||
mutes: NotificationMutes,
|
||||
accountId: string,
|
||||
mailboxId: string,
|
||||
muted: boolean,
|
||||
): NotificationMutes {
|
||||
const mailboxes = { ...mutes.mailboxes };
|
||||
const key = mailboxKey(accountId, mailboxId);
|
||||
if (muted === isAccountMuted(mutes, accountId)) delete mailboxes[key];
|
||||
else mailboxes[key] = muted;
|
||||
return { ...mutes, mailboxes };
|
||||
}
|
||||
|
||||
/** Drops every folder setting of one account so all its folders follow the account again. */
|
||||
export function resetMailboxMutes(mutes: NotificationMutes, accountId: string): NotificationMutes {
|
||||
const prefix = mailboxKey(accountId, '');
|
||||
const mailboxes = Object.fromEntries(
|
||||
Object.entries(mutes.mailboxes).filter(([key]) => !key.startsWith(prefix)),
|
||||
);
|
||||
return { ...mutes, mailboxes };
|
||||
}
|
||||
+56
-11
@@ -8,6 +8,13 @@ import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
import type { EmailPushConfig, Mailbox } from '@/lib/jmap/types';
|
||||
import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import {
|
||||
EMPTY_NOTIFICATION_MUTES,
|
||||
isAccountMuted,
|
||||
isMailboxMuted,
|
||||
mailboxOverridesForAccount,
|
||||
type NotificationMutes,
|
||||
} from '@/lib/notification-mutes';
|
||||
|
||||
// Per-account keys: a single browser may be signed in to multiple accounts,
|
||||
// each with its own JMAP PushSubscription and its own relay record. Scoping
|
||||
@@ -86,10 +93,16 @@ export function serverSupportsEmailPush(client: IJMAPClient): boolean {
|
||||
* account with no Inbox we can see (someone shared a single folder with us)
|
||||
* gets a filter that never matches: leaving it out of the map would make the
|
||||
* server fall back to unfiltered pushes for that account.
|
||||
*
|
||||
* Muted accounts and folders (#1169) are cut out of the same filter. In the
|
||||
* default mode a muted folder joins Junk in the "not only in" list; a muted
|
||||
* account only lets through mail in the folders the user unmuted again, or
|
||||
* nothing at all. In inbox-only mode a muted Inbox silences the account.
|
||||
*/
|
||||
export async function buildEmailPushConfig(
|
||||
client: IJMAPClient,
|
||||
inboxOnly: boolean,
|
||||
mutes: NotificationMutes = EMPTY_NOTIFICATION_MUTES,
|
||||
): Promise<Record<string, EmailPushConfig>> {
|
||||
const primary = client.getAccountId();
|
||||
// Every account that has any mailbox gets a filter entry - a secondary/shared
|
||||
@@ -98,10 +111,13 @@ export async function buildEmailPushConfig(
|
||||
const accountIds = new Set<string>([primary]);
|
||||
const junkByAccount = new Map<string, string[]>();
|
||||
const inboxByAccount = new Map<string, string>();
|
||||
const mailboxIdsByAccount = new Map<string, Set<string>>();
|
||||
// In inbox-only mode a swallowed fetch failure would masquerade as "no Inbox
|
||||
// mailbox" below, so let the real error surface; the default mode can still
|
||||
// degrade to keyword-only filtering on a transient miss.
|
||||
const mailboxes = inboxOnly
|
||||
// mailbox" below, and with mutes it would drop them all and unmute
|
||||
// everything, so let the real error surface; otherwise the default mode can
|
||||
// still degrade to keyword-only filtering on a transient miss.
|
||||
const hasMutes = mutes.accounts.length > 0 || Object.keys(mutes.mailboxes).length > 0;
|
||||
const mailboxes = inboxOnly || hasMutes
|
||||
? await client.getAllMailboxes()
|
||||
: await client.getAllMailboxes().catch(() => [] as Mailbox[]);
|
||||
for (const m of mailboxes) {
|
||||
@@ -109,17 +125,24 @@ export async function buildEmailPushConfig(
|
||||
accountIds.add(accountId);
|
||||
// Shared-account mailboxes carry a client-side "<account>:<id>" id;
|
||||
// the server only knows the original.
|
||||
const serverId = m.originalId ?? m.id;
|
||||
const ids = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
|
||||
ids.add(serverId);
|
||||
mailboxIdsByAccount.set(accountId, ids);
|
||||
if (m.role === 'junk') {
|
||||
const junk = junkByAccount.get(accountId) ?? [];
|
||||
junk.push(m.originalId ?? m.id);
|
||||
junk.push(serverId);
|
||||
junkByAccount.set(accountId, junk);
|
||||
}
|
||||
if (m.role === 'inbox') inboxByAccount.set(accountId, m.originalId ?? m.id);
|
||||
if (m.role === 'inbox') inboxByAccount.set(accountId, serverId);
|
||||
}
|
||||
|
||||
const config: Record<string, EmailPushConfig> = {};
|
||||
for (const accountId of accountIds) {
|
||||
const junkIds = junkByAccount.get(accountId) ?? [];
|
||||
// Settings for folders that no longer exist are left out.
|
||||
const existing = mailboxIdsByAccount.get(accountId) ?? new Set<string>();
|
||||
const overrides = mailboxOverridesForAccount(mutes, accountId);
|
||||
const conditions: Record<string, unknown>[] = [{ notKeyword: '$junk' }];
|
||||
if (inboxOnly) {
|
||||
const inboxId = inboxByAccount.get(accountId);
|
||||
@@ -128,9 +151,19 @@ export async function buildEmailPushConfig(
|
||||
if (!inboxId && accountId === primary) {
|
||||
throw new Error(`No Inbox mailbox found for account ${accountId}; cannot build an inbox-only push filter`);
|
||||
}
|
||||
conditions.push(inboxId ? { inMailbox: inboxId } : { hasKeyword: '$junk' });
|
||||
} else if (junkIds.length > 0) {
|
||||
conditions.push({ inMailboxOtherThan: [...junkIds].sort() });
|
||||
conditions.push(inboxId && !isMailboxMuted(mutes, accountId, inboxId)
|
||||
? { inMailbox: inboxId }
|
||||
: NEVER_MATCH);
|
||||
} else if (isAccountMuted(mutes, accountId)) {
|
||||
const allowed = overrides.unmuted
|
||||
.filter((id) => existing.has(id) && !junkIds.includes(id))
|
||||
.sort();
|
||||
if (allowed.length === 0) conditions.push(NEVER_MATCH);
|
||||
else if (allowed.length === 1) conditions.push({ inMailbox: allowed[0] });
|
||||
else conditions.push({ operator: 'OR', conditions: allowed.map((id) => ({ inMailbox: id })) });
|
||||
} else {
|
||||
const excluded = new Set([...junkIds, ...overrides.muted.filter((id) => existing.has(id))]);
|
||||
if (excluded.size > 0) conditions.push({ inMailboxOtherThan: [...excluded].sort() });
|
||||
}
|
||||
config[accountId] = {
|
||||
// Always the operator form: that's how the server echoes it back, so a
|
||||
@@ -143,6 +176,10 @@ export async function buildEmailPushConfig(
|
||||
return config;
|
||||
}
|
||||
|
||||
// Contradicts the `notKeyword: '$junk'` every filter starts with, so the AND
|
||||
// never matches: the account stays in the map but never pushes.
|
||||
const NEVER_MATCH = { hasKeyword: '$junk' } as const;
|
||||
|
||||
function normalizeEmailPush(value: unknown): string {
|
||||
const sortKeys = (v: unknown): unknown => {
|
||||
if (Array.isArray(v)) return v.map(sortKeys);
|
||||
@@ -181,6 +218,8 @@ export interface EnableWebPushParams {
|
||||
// pushNotifyInboxOnly. Required, not optional: it changes the server-side
|
||||
// delivery filter, so every caller must decide explicitly.
|
||||
inboxOnly: boolean;
|
||||
// Muted accounts and folders, settings-store's notificationMutes (#1169).
|
||||
mutes?: NotificationMutes;
|
||||
}
|
||||
|
||||
export interface EnableWebPushResult {
|
||||
@@ -513,7 +552,7 @@ export async function enableWebPush(
|
||||
// revoked shared-mailbox access (#841).
|
||||
const existingSubs = await params.client.listPushSubscriptions().catch(() => []);
|
||||
const emailPush = serverSupportsEmailPush(params.client)
|
||||
? await buildEmailPushConfig(params.client, params.inboxOnly)
|
||||
? await buildEmailPushConfig(params.client, params.inboxOnly, params.mutes)
|
||||
: null;
|
||||
const subIdKey = subscriptionIdKey(accountId);
|
||||
const storedServerId = localStorage.getItem(subIdKey);
|
||||
@@ -731,6 +770,10 @@ export interface ResyncWebPushParams {
|
||||
accountLabel?: string;
|
||||
// Mirrors settings-store's pushNotifyInboxOnly - see EnableWebPushParams.
|
||||
inboxOnly: boolean;
|
||||
mutes?: NotificationMutes;
|
||||
// Skip the once-a-day limit: the filter itself changed (a folder was
|
||||
// muted) and has to reach the server now.
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -749,7 +792,7 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
|
||||
}
|
||||
if (!accountId) return false;
|
||||
const last = lastResyncAt.get(accountId);
|
||||
if (last !== undefined && Date.now() - last < RESYNC_INTERVAL_MS) return false;
|
||||
if (!params.force && last !== undefined && Date.now() - last < RESYNC_INTERVAL_MS) return false;
|
||||
try {
|
||||
// The browser may have lost its endpoint while our saved opt-in and
|
||||
// server registration survived. Recreate it through the normal enable
|
||||
@@ -763,6 +806,7 @@ export async function resyncWebPush(params: ResyncWebPushParams): Promise<boolea
|
||||
relayBaseUrl: params.relayBaseUrl,
|
||||
accountLabel: params.accountLabel,
|
||||
inboxOnly: params.inboxOnly,
|
||||
mutes: params.mutes,
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
@@ -804,7 +848,7 @@ export interface BulkWebPushResult {
|
||||
export async function enableWebPushForAccounts(
|
||||
targets: BulkWebPushTarget[],
|
||||
// inboxOnly mirrors settings-store's pushNotifyInboxOnly, as for a single account.
|
||||
options: { relayBaseUrl?: string; forceRecreate?: boolean; inboxOnly: boolean },
|
||||
options: { relayBaseUrl?: string; forceRecreate?: boolean; inboxOnly: boolean; mutes?: NotificationMutes },
|
||||
): Promise<BulkWebPushResult> {
|
||||
const result: BulkWebPushResult = { enabled: [], failed: [] };
|
||||
let browserRefusal: unknown;
|
||||
@@ -820,6 +864,7 @@ export async function enableWebPushForAccounts(
|
||||
accountLabel: target.accountLabel,
|
||||
forceRecreate: options.forceRecreate,
|
||||
inboxOnly: options.inboxOnly,
|
||||
mutes: options.mutes,
|
||||
});
|
||||
result.enabled.push(target.accountId);
|
||||
} catch (error) {
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "أقل",
|
||||
"shared": "مشترك",
|
||||
"shared_account_hidden": "تم الإخفاء من الشريط الجانبي. أعد إظهاره من الإعدادات › التخطيط.",
|
||||
"notifications_muted": "الإشعارات مكتومة",
|
||||
"mail": "البريد",
|
||||
"nav_label": "التنقل",
|
||||
"add_app": "التطبيقات",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "حذف المجلد",
|
||||
"refresh": "تحديث",
|
||||
"hide_shared_account": "إخفاء من الشريط الجانبي",
|
||||
"mute_notifications": "كتم الإشعارات",
|
||||
"unmute_notifications": "إلغاء كتم الإشعارات",
|
||||
"mute_account_notifications": "كتم إشعارات هذا الحساب",
|
||||
"unmute_account_notifications": "إلغاء كتم إشعارات هذا الحساب",
|
||||
"reset_folder_notifications": "إعادة تعيين إشعارات المجلدات",
|
||||
"mark_all_confirm_title": "تمييز جميع المجلدات كمقروءة",
|
||||
"mark_all_confirm_message": "هل تريد تمييز كل رسالة غير مقروءة في حسابك الشخصي كمقروءة؟",
|
||||
"delete_confirm_title": "حذف المجلد",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Menys",
|
||||
"shared": "Compartit",
|
||||
"shared_account_hidden": "Amagat de la barra lateral. Torna'l a mostrar a Configuració › Disposició.",
|
||||
"notifications_muted": "Notificacions silenciades",
|
||||
"mail": "Correu",
|
||||
"nav_label": "Navegació",
|
||||
"add_app": "Aplicacions",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Suprimeix la carpeta",
|
||||
"refresh": "Actualitza",
|
||||
"hide_shared_account": "Amaga de la barra lateral",
|
||||
"mute_notifications": "Silencia les notificacions",
|
||||
"unmute_notifications": "Activa les notificacions",
|
||||
"mute_account_notifications": "Silencia les notificacions d'aquest compte",
|
||||
"unmute_account_notifications": "Activa les notificacions d'aquest compte",
|
||||
"reset_folder_notifications": "Restableix les notificacions de les carpetes",
|
||||
"mark_all_confirm_title": "Marca totes les carpetes com a llegides",
|
||||
"mark_all_confirm_message": "Voleu marcar tots els missatges no llegits del vostre compte personal com a llegits?",
|
||||
"delete_confirm_title": "Suprimeix la carpeta",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Méně",
|
||||
"shared": "Sdílené",
|
||||
"shared_account_hidden": "Skryto z postranního panelu. Znovu zobrazíte v Nastavení › Vzhled.",
|
||||
"notifications_muted": "Oznámení ztlumena",
|
||||
"mail": "Pošta",
|
||||
"nav_label": "Navigace",
|
||||
"add_app": "Aplikace",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Smazat složku",
|
||||
"refresh": "Obnovit",
|
||||
"hide_shared_account": "Skrýt z postranního panelu",
|
||||
"mute_notifications": "Ztlumit oznámení",
|
||||
"unmute_notifications": "Zapnout oznámení",
|
||||
"mute_account_notifications": "Ztlumit oznámení tohoto účtu",
|
||||
"unmute_account_notifications": "Zapnout oznámení tohoto účtu",
|
||||
"reset_folder_notifications": "Obnovit oznámení složek",
|
||||
"mark_all_confirm_title": "Označit všechny složky jako přečtené",
|
||||
"mark_all_confirm_message": "Označit všechny nepřečtené zprávy ve vašem osobním účtu jako přečtené?",
|
||||
"delete_confirm_title": "Smazat složku",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Mindre",
|
||||
"shared": "Delt",
|
||||
"shared_account_hidden": "Skjult i sidepanelet. Vis den igen under Indstillinger › Layout.",
|
||||
"notifications_muted": "Notifikationer slået fra",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Slet mappe",
|
||||
"refresh": "Opdatér",
|
||||
"hide_shared_account": "Skjul i sidepanelet",
|
||||
"mute_notifications": "Slå notifikationer fra",
|
||||
"unmute_notifications": "Slå notifikationer til",
|
||||
"mute_account_notifications": "Slå notifikationer fra for denne konto",
|
||||
"unmute_account_notifications": "Slå notifikationer til for denne konto",
|
||||
"reset_folder_notifications": "Nulstil mappernes notifikationer",
|
||||
"mark_all_confirm_title": "Markér alle mapper som læst",
|
||||
"mark_all_confirm_message": "Markér alle ulæste beskeder på din personlige konto som læst?",
|
||||
"delete_confirm_title": "Slet mappe",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Geteilt",
|
||||
"shared_account_hidden": "In der Seitenleiste ausgeblendet. Unter Einstellungen › Layout wieder einblenden.",
|
||||
"notifications_muted": "Benachrichtigungen stummgeschaltet",
|
||||
"scheduled": "Geplant",
|
||||
"unified_all_unread": "Alle ungelesenen",
|
||||
"unified_all_starred": "Alle markierten",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Ordner löschen",
|
||||
"refresh": "Aktualisieren",
|
||||
"hide_shared_account": "In Seitenleiste ausblenden",
|
||||
"mute_notifications": "Benachrichtigungen stummschalten",
|
||||
"unmute_notifications": "Benachrichtigungen einschalten",
|
||||
"mute_account_notifications": "Benachrichtigungen für dieses Konto stummschalten",
|
||||
"unmute_account_notifications": "Benachrichtigungen für dieses Konto einschalten",
|
||||
"reset_folder_notifications": "Ordner-Benachrichtigungen zurücksetzen",
|
||||
"mark_all_confirm_title": "Alle Ordner als gelesen markieren",
|
||||
"mark_all_confirm_message": "Jede ungelesene Nachricht in deinem persönlichen Konto als gelesen markieren?",
|
||||
"delete_confirm_title": "Ordner löschen",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Less",
|
||||
"shared": "Shared",
|
||||
"shared_account_hidden": "Hidden from the sidebar. Turn it back on in Settings › Layout.",
|
||||
"notifications_muted": "Notifications muted",
|
||||
"mail": "Mail",
|
||||
"nav_label": "Navigation",
|
||||
"add_app": "Apps",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Delete folder",
|
||||
"refresh": "Refresh",
|
||||
"hide_shared_account": "Hide from sidebar",
|
||||
"mute_notifications": "Mute notifications",
|
||||
"unmute_notifications": "Unmute notifications",
|
||||
"mute_account_notifications": "Mute notifications for this account",
|
||||
"unmute_account_notifications": "Unmute notifications for this account",
|
||||
"reset_folder_notifications": "Reset folder notification settings",
|
||||
"mark_all_confirm_title": "Mark all folders as read",
|
||||
"mark_all_confirm_message": "Mark every unread message in your personal account as read?",
|
||||
"delete_confirm_title": "Delete folder",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartido",
|
||||
"shared_account_hidden": "Oculta en la barra lateral. Vuelve a mostrarla en Configuración › Diseño.",
|
||||
"notifications_muted": "Notificaciones silenciadas",
|
||||
"scheduled": "Programados",
|
||||
"unified_all_unread": "Todo no leído",
|
||||
"unified_all_starred": "Todo destacado",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Eliminar carpeta",
|
||||
"refresh": "Actualizar",
|
||||
"hide_shared_account": "Ocultar de la barra lateral",
|
||||
"mute_notifications": "Silenciar notificaciones",
|
||||
"unmute_notifications": "Activar notificaciones",
|
||||
"mute_account_notifications": "Silenciar notificaciones de esta cuenta",
|
||||
"unmute_account_notifications": "Activar notificaciones de esta cuenta",
|
||||
"reset_folder_notifications": "Restablecer notificaciones de carpetas",
|
||||
"mark_all_confirm_title": "Marcar todas las carpetas como leídas",
|
||||
"mark_all_confirm_message": "¿Marcar todos los mensajes no leídos de su cuenta personal como leídos?",
|
||||
"delete_confirm_title": "Eliminar carpeta",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "کمتر",
|
||||
"shared": "اشتراکی",
|
||||
"shared_account_hidden": "از نوار کناری پنهان شد. از تنظیمات › چیدمان دوباره نمایش دهید.",
|
||||
"notifications_muted": "اعلانها بیصدا",
|
||||
"mail": "ایمیل",
|
||||
"nav_label": "ناوبری",
|
||||
"add_app": "برنامهها",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "حذف پوشه",
|
||||
"refresh": "بازنشانی",
|
||||
"hide_shared_account": "پنهان کردن از نوار کناری",
|
||||
"mute_notifications": "بیصدا کردن اعلانها",
|
||||
"unmute_notifications": "فعال کردن اعلانها",
|
||||
"mute_account_notifications": "بیصدا کردن اعلانهای این حساب",
|
||||
"unmute_account_notifications": "فعال کردن اعلانهای این حساب",
|
||||
"reset_folder_notifications": "بازنشانی اعلانهای پوشهها",
|
||||
"mark_all_confirm_title": "علامتگذاری همه",
|
||||
"mark_all_confirm_message": "همه پیامهای خوانده نشده علامتگذاری شوند؟",
|
||||
"delete_confirm_title": "حذف پوشه",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Partagé",
|
||||
"shared_account_hidden": "Masqué de la barre latérale. Réaffichez-le dans Paramètres › Mise en page.",
|
||||
"notifications_muted": "Notifications désactivées",
|
||||
"scheduled": "Planifiés",
|
||||
"unified_all_unread": "Tous les non lus",
|
||||
"unified_all_starred": "Tous les favoris",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Supprimer le dossier",
|
||||
"refresh": "Actualiser",
|
||||
"hide_shared_account": "Masquer de la barre latérale",
|
||||
"mute_notifications": "Désactiver les notifications",
|
||||
"unmute_notifications": "Réactiver les notifications",
|
||||
"mute_account_notifications": "Désactiver les notifications de ce compte",
|
||||
"unmute_account_notifications": "Réactiver les notifications de ce compte",
|
||||
"reset_folder_notifications": "Réinitialiser les notifications des dossiers",
|
||||
"mark_all_confirm_title": "Marquer tous les dossiers comme lus",
|
||||
"mark_all_confirm_message": "Marquer tous les messages non lus de votre compte personnel comme lus ?",
|
||||
"delete_confirm_title": "Supprimer le dossier",
|
||||
|
||||
@@ -148,6 +148,7 @@
|
||||
"all_accounts_loading": "טוען חשבונות: {loaded} מתוך {total}",
|
||||
"shared": "משותף",
|
||||
"shared_account_hidden": "הוסתר מסרגל הצד. אפשר להציג שוב בהגדרות › פריסה.",
|
||||
"notifications_muted": "ההתראות מושתקות",
|
||||
"scheduled": "מתוכננים",
|
||||
"unified_all_unread": "הכל שלא נקראו",
|
||||
"unified_all_starred": "הכל מסומנים",
|
||||
@@ -3861,6 +3862,11 @@
|
||||
"delete_folder": "מחק תיקייה",
|
||||
"refresh": "רענן",
|
||||
"hide_shared_account": "הסתרה מסרגל הצד",
|
||||
"mute_notifications": "השתקת התראות",
|
||||
"unmute_notifications": "ביטול השתקת התראות",
|
||||
"mute_account_notifications": "השתקת התראות לחשבון זה",
|
||||
"unmute_account_notifications": "ביטול השתקת התראות לחשבון זה",
|
||||
"reset_folder_notifications": "איפוס התראות התיקיות",
|
||||
"mark_all_confirm_title": "סמן את כל התיקיות כקרויות",
|
||||
"mark_all_confirm_message": "סמן כל הודעה שלא קרויה בחשבון האישי שלך כקרויה?",
|
||||
"delete_confirm_title": "מחק תיקייה",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Kevesebb",
|
||||
"shared": "Megosztott",
|
||||
"shared_account_hidden": "Elrejtve az oldalsávról. A Beállítások › Elrendezés alatt jelenítheted meg újra.",
|
||||
"notifications_muted": "Értesítések némítva",
|
||||
"mail": "Levelek",
|
||||
"nav_label": "Navigáció",
|
||||
"add_app": "Alkalmazások",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Mappa törlése",
|
||||
"refresh": "Frissítés",
|
||||
"hide_shared_account": "Elrejtés az oldalsávról",
|
||||
"mute_notifications": "Értesítések némítása",
|
||||
"unmute_notifications": "Értesítések bekapcsolása",
|
||||
"mute_account_notifications": "Fiók értesítéseinek némítása",
|
||||
"unmute_account_notifications": "Fiók értesítéseinek bekapcsolása",
|
||||
"reset_folder_notifications": "Mappák értesítési beállításainak visszaállítása",
|
||||
"mark_all_confirm_title": "Összes mappa olvasottnak jelölése",
|
||||
"mark_all_confirm_message": "Minden olvasatlan üzenetet olvasottnak jelölsz a személyes fiókodban?",
|
||||
"delete_confirm_title": "Mappa törlése",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "App",
|
||||
"shared": "Condiviso",
|
||||
"shared_account_hidden": "Nascosto dalla barra laterale. Puoi mostrarlo di nuovo in Impostazioni › Layout.",
|
||||
"notifications_muted": "Notifiche disattivate",
|
||||
"scheduled": "Programmate",
|
||||
"unified_all_unread": "Tutti i non letti",
|
||||
"unified_all_starred": "Tutti gli speciali",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Elimina cartella",
|
||||
"refresh": "Aggiorna",
|
||||
"hide_shared_account": "Nascondi dalla barra laterale",
|
||||
"mute_notifications": "Disattiva notifiche",
|
||||
"unmute_notifications": "Riattiva notifiche",
|
||||
"mute_account_notifications": "Disattiva notifiche per questo account",
|
||||
"unmute_account_notifications": "Riattiva notifiche per questo account",
|
||||
"reset_folder_notifications": "Ripristina notifiche delle cartelle",
|
||||
"mark_all_confirm_title": "Segna tutte le cartelle come lette",
|
||||
"mark_all_confirm_message": "Segnare tutti i messaggi non letti del tuo account personale come letti?",
|
||||
"delete_confirm_title": "Elimina cartella",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "アプリ",
|
||||
"shared": "共有",
|
||||
"shared_account_hidden": "サイドバーから非表示にしました。設定 › レイアウトで再表示できます。",
|
||||
"notifications_muted": "通知ミュート中",
|
||||
"scheduled": "予約済み",
|
||||
"unified_all_unread": "すべての未読",
|
||||
"unified_all_starred": "すべてのスター付き",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "フォルダーを削除",
|
||||
"refresh": "更新",
|
||||
"hide_shared_account": "サイドバーから非表示",
|
||||
"mute_notifications": "通知をミュート",
|
||||
"unmute_notifications": "通知のミュートを解除",
|
||||
"mute_account_notifications": "このアカウントの通知をミュート",
|
||||
"unmute_account_notifications": "このアカウントの通知のミュートを解除",
|
||||
"reset_folder_notifications": "フォルダーの通知設定をリセット",
|
||||
"mark_all_confirm_title": "すべてのフォルダーを既読にする",
|
||||
"mark_all_confirm_message": "個人アカウントのすべての未読メッセージを既読にしますか?",
|
||||
"delete_confirm_title": "フォルダーを削除",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "앱",
|
||||
"shared": "공유됨",
|
||||
"shared_account_hidden": "사이드바에서 숨겼습니다. 설정 › 레이아웃에서 다시 표시할 수 있습니다.",
|
||||
"notifications_muted": "알림 꺼짐",
|
||||
"scheduled": "예약됨",
|
||||
"unified_all_unread": "모든 읽지 않음",
|
||||
"unified_all_starred": "모든 별표",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "폴더 삭제",
|
||||
"refresh": "새로 고침",
|
||||
"hide_shared_account": "사이드바에서 숨기기",
|
||||
"mute_notifications": "알림 끄기",
|
||||
"unmute_notifications": "알림 켜기",
|
||||
"mute_account_notifications": "이 계정의 알림 끄기",
|
||||
"unmute_account_notifications": "이 계정의 알림 켜기",
|
||||
"reset_folder_notifications": "폴더 알림 설정 초기화",
|
||||
"mark_all_confirm_title": "모든 폴더를 읽음으로 표시",
|
||||
"mark_all_confirm_message": "개인 계정의 모든 읽지 않은 메시지를 읽음으로 표시하시겠습니까?",
|
||||
"delete_confirm_title": "폴더 삭제",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Lietotnes",
|
||||
"shared": "Koplietots",
|
||||
"shared_account_hidden": "Paslēpts no sānjoslas. Atkal parādiet sadaļā Iestatījumi › Izkārtojums.",
|
||||
"notifications_muted": "Paziņojumi izslēgti",
|
||||
"scheduled": "Ieplānots",
|
||||
"unified_all_unread": "Visi nelasītie",
|
||||
"unified_all_starred": "Visi ar zvaigzni",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Dzēst mapi",
|
||||
"refresh": "Atjaunināt",
|
||||
"hide_shared_account": "Paslēpt no sānjoslas",
|
||||
"mute_notifications": "Izslēgt paziņojumus",
|
||||
"unmute_notifications": "Ieslēgt paziņojumus",
|
||||
"mute_account_notifications": "Izslēgt šī konta paziņojumus",
|
||||
"unmute_account_notifications": "Ieslēgt šī konta paziņojumus",
|
||||
"reset_folder_notifications": "Atiestatīt mapju paziņojumus",
|
||||
"mark_all_confirm_title": "Atzīmēt visas mapes kā lasītas",
|
||||
"mark_all_confirm_message": "Atzīmēt visas nelasītās ziņas jūsu personīgajā kontā kā lasītas?",
|
||||
"delete_confirm_title": "Dzēst mapi",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Бага",
|
||||
"shared": "Хуваалцсан",
|
||||
"shared_account_hidden": "Хажуугийн самбараас нуусан. Тохиргоо › Байршил хэсгээс дахин харуулна уу.",
|
||||
"notifications_muted": "Мэдэгдэл хаалттай",
|
||||
"mail": "Мэйл",
|
||||
"nav_label": "Навигаци",
|
||||
"add_app": "Програмууд",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Хавтас устгах",
|
||||
"refresh": "Сэргээх",
|
||||
"hide_shared_account": "Хажуугийн самбараас нуух",
|
||||
"mute_notifications": "Мэдэгдлийг хаах",
|
||||
"unmute_notifications": "Мэдэгдлийг нээх",
|
||||
"mute_account_notifications": "Энэ бүртгэлийн мэдэгдлийг хаах",
|
||||
"unmute_account_notifications": "Энэ бүртгэлийн мэдэгдлийг нээх",
|
||||
"reset_folder_notifications": "Хавтасны мэдэгдлийн тохиргоог сэргээх",
|
||||
"mark_all_confirm_title": "Бүх хавтсыг уншсан гэж тэмдэглэх",
|
||||
"mark_all_confirm_message": "Хувийн бүртгэлийнхээ уншаагүй мессеж бүрийг уншсан гэж тэмдэглэх үү?",
|
||||
"delete_confirm_title": "Хавтас устгах",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Mindre",
|
||||
"shared": "Delt",
|
||||
"shared_account_hidden": "Skjult fra sidepanelet. Vis den igjen under Innstillinger › Oppsett.",
|
||||
"notifications_muted": "Varsler dempet",
|
||||
"mail": "E-post",
|
||||
"nav_label": "Navigasjon",
|
||||
"add_app": "Apper",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Slett mappe",
|
||||
"refresh": "Oppdater",
|
||||
"hide_shared_account": "Skjul fra sidepanelet",
|
||||
"mute_notifications": "Demp varsler",
|
||||
"unmute_notifications": "Slå på varsler",
|
||||
"mute_account_notifications": "Demp varsler for denne kontoen",
|
||||
"unmute_account_notifications": "Slå på varsler for denne kontoen",
|
||||
"reset_folder_notifications": "Tilbakestill varsler for mapper",
|
||||
"mark_all_confirm_title": "Merk alle mapper som lest",
|
||||
"mark_all_confirm_message": "Vil du merke alle uleste meldinger på den personlige kontoen din som lest?",
|
||||
"delete_confirm_title": "Slett mappe",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Gedeeld",
|
||||
"shared_account_hidden": "Verborgen in de zijbalk. Zet het weer aan via Instellingen › Indeling.",
|
||||
"notifications_muted": "Meldingen gedempt",
|
||||
"scheduled": "Gepland",
|
||||
"unified_all_unread": "Alle ongelezen",
|
||||
"unified_all_starred": "Alle met ster",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Map verwijderen",
|
||||
"refresh": "Vernieuwen",
|
||||
"hide_shared_account": "Verbergen in zijbalk",
|
||||
"mute_notifications": "Meldingen dempen",
|
||||
"unmute_notifications": "Meldingen inschakelen",
|
||||
"mute_account_notifications": "Meldingen voor dit account dempen",
|
||||
"unmute_account_notifications": "Meldingen voor dit account inschakelen",
|
||||
"reset_folder_notifications": "Meldingen van mappen herstellen",
|
||||
"mark_all_confirm_title": "Alle mappen markeren als gelezen",
|
||||
"mark_all_confirm_message": "Alle ongelezen berichten in uw persoonlijke account als gelezen markeren?",
|
||||
"delete_confirm_title": "Map verwijderen",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Aplikacje",
|
||||
"shared": "Udostępnione",
|
||||
"shared_account_hidden": "Ukryto na pasku bocznym. Przywrócisz je w Ustawienia › Układ.",
|
||||
"notifications_muted": "Powiadomienia wyciszone",
|
||||
"scheduled": "Zaplanowane",
|
||||
"unified_all_unread": "Wszystkie nieprzeczytane",
|
||||
"unified_all_starred": "Wszystkie oznaczone gwiazdką",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Usuń folder",
|
||||
"refresh": "Odśwież",
|
||||
"hide_shared_account": "Ukryj na pasku bocznym",
|
||||
"mute_notifications": "Wycisz powiadomienia",
|
||||
"unmute_notifications": "Włącz powiadomienia",
|
||||
"mute_account_notifications": "Wycisz powiadomienia tego konta",
|
||||
"unmute_account_notifications": "Włącz powiadomienia tego konta",
|
||||
"reset_folder_notifications": "Resetuj powiadomienia folderów",
|
||||
"mark_all_confirm_title": "Oznacz wszystkie foldery jako przeczytane",
|
||||
"mark_all_confirm_message": "Oznaczyć wszystkie nieprzeczytane wiadomości na koncie osobistym jako przeczytane?",
|
||||
"delete_confirm_title": "Usuń folder",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Apps",
|
||||
"shared": "Compartilhado",
|
||||
"shared_account_hidden": "Oculto da barra lateral. Mostre de novo em Configurações › Layout.",
|
||||
"notifications_muted": "Notificações silenciadas",
|
||||
"scheduled": "Agendados",
|
||||
"unified_all_unread": "Tudo não lido",
|
||||
"unified_all_starred": "Tudo com estrela",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Excluir pasta",
|
||||
"refresh": "Atualizar",
|
||||
"hide_shared_account": "Ocultar da barra lateral",
|
||||
"mute_notifications": "Silenciar notificações",
|
||||
"unmute_notifications": "Reativar notificações",
|
||||
"mute_account_notifications": "Silenciar notificações desta conta",
|
||||
"unmute_account_notifications": "Reativar notificações desta conta",
|
||||
"reset_folder_notifications": "Redefinir notificações das pastas",
|
||||
"mark_all_confirm_title": "Marcar todas as pastas como lidas",
|
||||
"mark_all_confirm_message": "Marcar todas as mensagens não lidas da sua conta pessoal como lidas?",
|
||||
"delete_confirm_title": "Excluir pasta",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Mai puține",
|
||||
"shared": "Partajat",
|
||||
"shared_account_hidden": "Ascuns din bara laterală. Îl poți afișa din nou în Setări › Aspect.",
|
||||
"notifications_muted": "Notificări dezactivate",
|
||||
"mail": "E-mail",
|
||||
"nav_label": "Navigare",
|
||||
"add_app": "Aplicații",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Șterge dosarul",
|
||||
"refresh": "Reîmprospătează",
|
||||
"hide_shared_account": "Ascunde din bara laterală",
|
||||
"mute_notifications": "Dezactivează notificările",
|
||||
"unmute_notifications": "Activează notificările",
|
||||
"mute_account_notifications": "Dezactivează notificările pentru acest cont",
|
||||
"unmute_account_notifications": "Activează notificările pentru acest cont",
|
||||
"reset_folder_notifications": "Resetează notificările dosarelor",
|
||||
"mark_all_confirm_title": "Marcați toate folderele ca citite",
|
||||
"mark_all_confirm_message": "Doriți să marcați toate mesajele necitite din contul dvs. personal ca fiind citite?",
|
||||
"delete_confirm_title": "Șterge dosarul",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "Приложения",
|
||||
"shared": "Общие",
|
||||
"shared_account_hidden": "Скрыто с боковой панели. Вернуть можно в Настройки › Макет.",
|
||||
"notifications_muted": "Уведомления отключены",
|
||||
"scheduled": "Запланировано",
|
||||
"unified_all_unread": "Все непрочитанные",
|
||||
"unified_all_starred": "Все помеченные",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Удалить папку",
|
||||
"refresh": "Обновить",
|
||||
"hide_shared_account": "Скрыть с боковой панели",
|
||||
"mute_notifications": "Отключить уведомления",
|
||||
"unmute_notifications": "Включить уведомления",
|
||||
"mute_account_notifications": "Отключить уведомления для этого аккаунта",
|
||||
"unmute_account_notifications": "Включить уведомления для этого аккаунта",
|
||||
"reset_folder_notifications": "Сбросить уведомления папок",
|
||||
"mark_all_confirm_title": "Отметить все папки как прочитанные",
|
||||
"mark_all_confirm_message": "Отметить все непрочитанные сообщения в вашем личном аккаунте как прочитанные?",
|
||||
"delete_confirm_title": "Удалить папку",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Menej",
|
||||
"shared": "Zdieľané",
|
||||
"shared_account_hidden": "Skryté z bočného panela. Znova zobrazíte v Nastavenia › Rozloženie.",
|
||||
"notifications_muted": "Upozornenia stlmené",
|
||||
"mail": "Pošta",
|
||||
"nav_label": "Navigácia",
|
||||
"add_app": "Aplikácie",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Zmazať priečinok",
|
||||
"refresh": "Obnoviť",
|
||||
"hide_shared_account": "Skryť z bočného panela",
|
||||
"mute_notifications": "Stlmiť upozornenia",
|
||||
"unmute_notifications": "Zapnúť upozornenia",
|
||||
"mute_account_notifications": "Stlmiť upozornenia tohto účtu",
|
||||
"unmute_account_notifications": "Zapnúť upozornenia tohto účtu",
|
||||
"reset_folder_notifications": "Obnoviť upozornenia priečinkov",
|
||||
"mark_all_confirm_title": "Označiť všetky priečinky ako prečítané",
|
||||
"mark_all_confirm_message": "Označiť každú neprečítanú správu vo vašom osobnom účte ako prečítanú?",
|
||||
"delete_confirm_title": "Zmazať priečinok",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "Daha az",
|
||||
"shared": "Paylaşılan",
|
||||
"shared_account_hidden": "Kenar çubuğundan gizlendi. Ayarlar › Düzen bölümünden yeniden gösterebilirsiniz.",
|
||||
"notifications_muted": "Bildirimler sessizde",
|
||||
"mail": "Posta",
|
||||
"nav_label": "Gezinme",
|
||||
"add_app": "Uygulamalar",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Klasörü sil",
|
||||
"refresh": "Yenile",
|
||||
"hide_shared_account": "Kenar çubuğundan gizle",
|
||||
"mute_notifications": "Bildirimleri sessize al",
|
||||
"unmute_notifications": "Bildirimleri aç",
|
||||
"mute_account_notifications": "Bu hesabın bildirimlerini sessize al",
|
||||
"unmute_account_notifications": "Bu hesabın bildirimlerini aç",
|
||||
"reset_folder_notifications": "Klasör bildirim ayarlarını sıfırla",
|
||||
"mark_all_confirm_title": "Tüm klasörleri okundu olarak işaretle",
|
||||
"mark_all_confirm_message": "Kişisel hesabınızdaki tüm okunmamış iletiler okundu olarak işaretlensin mi?",
|
||||
"delete_confirm_title": "Klasörü sil",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "програми",
|
||||
"shared": "Спільні",
|
||||
"shared_account_hidden": "Приховано з бічної панелі. Повернути можна в Налаштування › Макет.",
|
||||
"notifications_muted": "Сповіщення вимкнено",
|
||||
"scheduled": "Заплановано",
|
||||
"unified_all_unread": "Усі непрочитані",
|
||||
"unified_all_starred": "Усі позначені",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "Видалити папку",
|
||||
"refresh": "Оновити",
|
||||
"hide_shared_account": "Приховати з бічної панелі",
|
||||
"mute_notifications": "Вимкнути сповіщення",
|
||||
"unmute_notifications": "Увімкнути сповіщення",
|
||||
"mute_account_notifications": "Вимкнути сповіщення для цього облікового запису",
|
||||
"unmute_account_notifications": "Увімкнути сповіщення для цього облікового запису",
|
||||
"reset_folder_notifications": "Скинути сповіщення папок",
|
||||
"mark_all_confirm_title": "Позначити всі папки як прочитані",
|
||||
"mark_all_confirm_message": "Позначити всі непрочитані повідомлення у вашому особистому акаунті як прочитані?",
|
||||
"delete_confirm_title": "Видалити папку",
|
||||
|
||||
@@ -146,6 +146,7 @@
|
||||
"show_fewer": "收合",
|
||||
"shared": "共用",
|
||||
"shared_account_hidden": "已從側邊欄隱藏。可在 設定 › 版面配置 中重新顯示。",
|
||||
"notifications_muted": "通知已靜音",
|
||||
"mail": "郵件",
|
||||
"nav_label": "導覽",
|
||||
"add_app": "應用程式",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "刪除信件匣",
|
||||
"refresh": "重新整理",
|
||||
"hide_shared_account": "從側邊欄隱藏",
|
||||
"mute_notifications": "靜音通知",
|
||||
"unmute_notifications": "取消靜音通知",
|
||||
"mute_account_notifications": "靜音此帳號的通知",
|
||||
"unmute_account_notifications": "取消靜音此帳號的通知",
|
||||
"reset_folder_notifications": "重設資料夾通知設定",
|
||||
"mark_all_confirm_title": "將所有信件匣標示為已讀",
|
||||
"mark_all_confirm_message": "要將您個人帳號中的所有未讀郵件標示為已讀嗎?",
|
||||
"delete_confirm_title": "刪除信件匣",
|
||||
|
||||
@@ -149,6 +149,7 @@
|
||||
"add_app": "应用",
|
||||
"shared": "共享",
|
||||
"shared_account_hidden": "已从侧边栏隐藏。可在 设置 › 布局 中重新显示。",
|
||||
"notifications_muted": "通知已静音",
|
||||
"scheduled": "已计划",
|
||||
"unified_all_unread": "全部未读",
|
||||
"unified_all_starred": "全部加星标",
|
||||
@@ -2549,6 +2550,11 @@
|
||||
"delete_folder": "删除文件夹",
|
||||
"refresh": "刷新",
|
||||
"hide_shared_account": "从侧边栏隐藏",
|
||||
"mute_notifications": "静音通知",
|
||||
"unmute_notifications": "取消静音通知",
|
||||
"mute_account_notifications": "静音此账户的通知",
|
||||
"unmute_account_notifications": "取消静音此账户的通知",
|
||||
"reset_folder_notifications": "重置文件夹通知设置",
|
||||
"mark_all_confirm_title": "将所有文件夹标记为已读",
|
||||
"mark_all_confirm_message": "将您个人账户中的所有未读邮件标记为已读?",
|
||||
"delete_confirm_title": "删除文件夹",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { useEmailStore } from '../email-store';
|
||||
import { useSettingsStore } from '../settings-store';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||
|
||||
// #1169: new mail in a muted Inbox, or an Inbox of a muted account, does not
|
||||
// raise the in-app new-mail notification (its sound).
|
||||
|
||||
const inbox = { id: 'inbox', role: 'inbox', isShared: false } as Mailbox;
|
||||
|
||||
function email(id: string, receivedAt: string): Email {
|
||||
return {
|
||||
id, threadId: `thread-${id}`, mailboxIds: { inbox: true }, keywords: {},
|
||||
subject: id, receivedAt, from: [{ email: 'sender@example.com' }], to: [],
|
||||
preview: '', size: 1, hasAttachment: false,
|
||||
} as Email;
|
||||
}
|
||||
|
||||
function client(rows: Email[]) {
|
||||
return {
|
||||
getEmails: vi.fn(async () => ({ emails: rows, total: rows.length, hasMore: false, state: 's' })),
|
||||
getThreads: vi.fn(async () => []),
|
||||
getAccountId: () => 'me',
|
||||
} as unknown as IJMAPClient;
|
||||
}
|
||||
|
||||
async function arrive() {
|
||||
const old = email('old', '2026-10-01T00:00:00Z');
|
||||
useEmailStore.setState({ emails: [old], totalEmails: 1 });
|
||||
await useEmailStore.getState().refreshCurrentMailbox(client([email('new', '2026-10-15T00:00:00Z'), old]));
|
||||
return useEmailStore.getState().newEmailNotification?.id ?? null;
|
||||
}
|
||||
|
||||
describe('new-mail notification in a muted Inbox (#1169)', () => {
|
||||
beforeEach(() => {
|
||||
useAuthStore.setState(useAuthStore.getInitialState());
|
||||
useSettingsStore.setState({
|
||||
emailsPerPage: 25, messageListOrder: [], messageListOrderScope: 'inbox',
|
||||
notificationMutes: { accounts: [], mailboxes: {} },
|
||||
});
|
||||
useEmailStore.setState({
|
||||
...useEmailStore.getInitialState(),
|
||||
selectedMailbox: 'inbox', mailboxes: [inbox],
|
||||
});
|
||||
});
|
||||
|
||||
it('notifies for new Inbox mail by default', async () => {
|
||||
expect(await arrive()).toBe('new');
|
||||
});
|
||||
|
||||
it('stays quiet when the Inbox is muted', async () => {
|
||||
useSettingsStore.setState({ notificationMutes: { accounts: [], mailboxes: { 'me/inbox': true } } });
|
||||
expect(await arrive()).toBeNull();
|
||||
});
|
||||
|
||||
it('stays quiet when the account is muted, unless the Inbox is unmuted again', async () => {
|
||||
useSettingsStore.setState({ notificationMutes: { accounts: ['me'], mailboxes: {} } });
|
||||
expect(await arrive()).toBeNull();
|
||||
|
||||
useEmailStore.setState({ newEmailNotification: null });
|
||||
useSettingsStore.setState({ notificationMutes: { accounts: ['me'], mailboxes: { 'me/inbox': false } } });
|
||||
expect(await arrive()).toBe('new');
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import type { IJMAPClient, SearchEmailsResult } from "@/lib/jmap/client-interfac
|
||||
import { withSearchSnippets, type DeferredSnippetBatch } from "@/lib/search-snippet";
|
||||
import { afterRequestDispatch } from "@/lib/after-request-dispatch";
|
||||
import { useSettingsStore, getMessageListOrderFor } from "@/stores/settings-store";
|
||||
import { isMailboxMuted } from "@/lib/notification-mutes";
|
||||
import { useCalendarStore } from "@/stores/calendar-store";
|
||||
import { orderKeywords, placeHeldRows, sanitizeSortLevels, type HeldKeywords, type SortLevel } from "@/lib/message-list-order";
|
||||
import { SearchFilters, DEFAULT_SEARCH_FILTERS, buildJMAPFilter, isFilterEmpty } from "@/lib/jmap/search-utils";
|
||||
@@ -5237,7 +5238,12 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
!currentEmails.some(e => e.id === newFirst.id) &&
|
||||
(!keepOpenThread || insertedCount > 0)
|
||||
) {
|
||||
get().handleNewEmailNotification(newFirst);
|
||||
// A muted Inbox (or account) stays quiet in the app too (#1169).
|
||||
const accountId = mailbox.accountId || resolveActionClient(client).getAccountId();
|
||||
const muted = isMailboxMuted(
|
||||
useSettingsStore.getState().notificationMutes, accountId, mailbox.originalId || mailbox.id,
|
||||
);
|
||||
if (!muted) get().handleNewEmailNotification(newFirst);
|
||||
}
|
||||
|
||||
// Merge the refreshed first page with the existing loaded emails.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useThemeStore } from './theme-store';
|
||||
import { useLocaleStore } from './locale-store';
|
||||
import type { EmailTemplate } from '@/lib/template-types';
|
||||
import type { NotificationSoundChoice } from '@/lib/notification-sound';
|
||||
import { EMPTY_NOTIFICATION_MUTES, normalizeNotificationMutes, type NotificationMutes } from '@/lib/notification-mutes';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { settingsHooks } from '@/lib/plugin-hooks';
|
||||
import { generateAccountId } from '@/lib/account-utils';
|
||||
@@ -551,6 +552,8 @@ interface SettingsState {
|
||||
notificationSoundChoice: NotificationSoundChoice;
|
||||
/** Web Push only fires for mail that lands in the Inbox; Sieve-filed mail stays silent. */
|
||||
pushNotifyInboxOnly: boolean;
|
||||
/** Accounts and folders that never notify (#1169); see lib/notification-mutes. */
|
||||
notificationMutes: NotificationMutes;
|
||||
/** Chosen Web Push relay URL. Empty = the admin-configured default. */
|
||||
pushRelayUrl: string;
|
||||
|
||||
@@ -817,6 +820,7 @@ const DEFAULT_SETTINGS = {
|
||||
emailNotificationSound: true,
|
||||
notificationSoundChoice: 'default' as NotificationSoundChoice,
|
||||
pushNotifyInboxOnly: false,
|
||||
notificationMutes: EMPTY_NOTIFICATION_MUTES,
|
||||
pushRelayUrl: '',
|
||||
|
||||
// Protocol Handlers
|
||||
@@ -1044,6 +1048,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
emailNotificationSound: state.emailNotificationSound,
|
||||
notificationSoundChoice: state.notificationSoundChoice,
|
||||
pushNotifyInboxOnly: state.pushNotifyInboxOnly,
|
||||
notificationMutes: state.notificationMutes,
|
||||
pushRelayUrl: state.pushRelayUrl,
|
||||
protocolOpenMode: state.protocolOpenMode,
|
||||
calendarNotificationsEnabled: state.calendarNotificationsEnabled,
|
||||
@@ -1196,6 +1201,10 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (key === 'hiddenSharedMailAccounts' && !isPlainRecord(settings[key])) {
|
||||
return;
|
||||
}
|
||||
if (key === 'notificationMutes') {
|
||||
put(key, { notificationMutes: normalizeNotificationMutes(settings[key]) });
|
||||
return;
|
||||
}
|
||||
// The list order is sent to the server as a sort array, so an
|
||||
// unknown criterion from a newer/older client must never get
|
||||
// through (an unsupportedSort refusal empties the folder).
|
||||
@@ -1528,6 +1537,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
if (!isPlainRecord(state.hiddenSharedMailAccounts)) {
|
||||
state.hiddenSharedMailAccounts = {};
|
||||
}
|
||||
state.notificationMutes = normalizeNotificationMutes(state.notificationMutes);
|
||||
state.messageListOrder = sanitizeSortLevels(state.messageListOrder);
|
||||
if (state.messageListOrderScope !== 'inbox' && state.messageListOrderScope !== 'all') {
|
||||
state.messageListOrderScope = 'inbox';
|
||||
|
||||
Reference in New Issue
Block a user