feat: show unread count badge on favicon
Closes #560. Composes the active inbox's unread count over the base favicon as an SVG badge, served as a percent-encoded data: URL, so new mail is visible on a tab that is not focused — including when the browser collapses tabs to icon-only, where a title-based count disappears entirely. The base icon is read from the rendered <link rel="icon"> rather than from config, so admin and per-domain branding overrides are inherited for free: the count is drawn on whatever logo the deployment actually serves. Keeping the badge in SVG rather than rasterising to a canvas also means the browser can rasterise it at whatever size it asks for, so a HiDPI tab is not served a 16px bitmap. Notes on the approach: - The badge link is an *additional* icon link that we append and mark as ours; we never remove or mutate a link we did not create. Next's metadata icons are rendered by React, which keeps a fiber pointing at that DOM node, so removing it would leave React holding a detached node and throw "Cannot read properties of null (reading 'removeChild')" on the next commit that deletes the fiber. Appending instead means the last-declared icon wins, and non-SVG fallback links survive with their type/sizes intact. (The usual recipe for this feature — assign canvas.toDataURL() to the existing link's href — does both of the things that break here.) - Every change of state is an *insertion* of a fresh link of ours, never a mutation or a removal, because that is the only signal a browser reliably re-reads the favicon on. Firefox ignores an in-place href change, and it equally ignores a removal — so clearing the badge by deleting our link left a stale count painted on the tab until a hard reload. Clearing it instead inserts a new link of ours carrying the original base href. - Holding last place has to be defended: on a client-side navigation React re-hoists its metadata icon link into <head>, landing after ours, and the base icon silently wins again. A MutationObserver on <head> moves our own link back to the end whenever a foreign icon link appears — moving only our node, never anyone else's. It no-ops once ours is last again, so a move cannot feed itself. - The badge is a full-width band across the foot of the icon, drawn to the metrics measured from Gmail's own 16px favicon: band height 0.625 of the icon, digit cap height 0.44, flush to the edges, corners rounded by about a pixel. Full width is what keeps a three-glyph label legible — rounded ends waste exactly the horizontal space it needs. Neutral white with black digits rather than the conventional red: faviconUrl is admin-overridable and Bulwark's own icon is rgb(219,45,84), so a red badge sat red-on-red. - The base SVG may be admin-uploaded, and the branding route deliberately serves it under a sandboxing CSP because SVG can carry script. Re-emitting it as a same-origin data: URL would un-fence that, so script, foreignObject and every on* handler are stripped before serialising. - Mounted in the root layout, not on the mail route: the badge belongs to the tab, so mounting it on the page would clear it on every hop to settings, calendar or contacts.
This commit is contained in:
@@ -4,6 +4,7 @@ import { Geist, Geist_Mono } from "next/font/google";
|
|||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { getLocale, getTranslations } from "next-intl/server";
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||||
|
import { FaviconBadge } from "@/components/favicon-badge";
|
||||||
import { configManager } from "@/lib/admin/config-manager";
|
import { configManager } from "@/lib/admin/config-manager";
|
||||||
import {
|
import {
|
||||||
matchDomainBranding,
|
matchDomainBranding,
|
||||||
@@ -132,6 +133,7 @@ export default async function RootLayout({
|
|||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
<ServiceWorkerRegistration />
|
<ServiceWorkerRegistration />
|
||||||
|
<FaviconBadge />
|
||||||
{children}
|
{children}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { FaviconBadge } from '@/components/favicon-badge';
|
||||||
|
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
||||||
|
import { useEmailStore } from '@/stores/email-store';
|
||||||
|
import { useSettingsStore } from '@/stores/settings-store';
|
||||||
|
import type { Mailbox } from '@/lib/jmap/types';
|
||||||
|
|
||||||
|
vi.mock('@/hooks/use-favicon-badge', () => ({
|
||||||
|
useFaviconBadge: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const useFaviconBadgeMock = vi.mocked(useFaviconBadge);
|
||||||
|
|
||||||
|
function mailbox(patch: Partial<Mailbox> & { id: string }): Mailbox {
|
||||||
|
return {
|
||||||
|
name: patch.id,
|
||||||
|
sortOrder: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
isSubscribed: true,
|
||||||
|
myRights: {
|
||||||
|
mayReadItems: true,
|
||||||
|
mayAddItems: true,
|
||||||
|
mayRemoveItems: true,
|
||||||
|
maySetSeen: true,
|
||||||
|
maySetKeywords: true,
|
||||||
|
mayCreateChild: true,
|
||||||
|
mayRename: true,
|
||||||
|
mayDelete: true,
|
||||||
|
maySubmit: true,
|
||||||
|
},
|
||||||
|
...patch,
|
||||||
|
} as Mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialMailboxes = useEmailStore.getState().mailboxes;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
useEmailStore.setState({ mailboxes: initialMailboxes });
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: true });
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('FaviconBadge', () => {
|
||||||
|
it('badges the unread count of the primary inbox', () => {
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, true);
|
||||||
|
expect(container.firstChild).toBeNull(); // renders no markup
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the badge when the setting is off', () => {
|
||||||
|
useSettingsStore.setState({ faviconUnreadBadge: false });
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [mailbox({ id: 'inbox', role: 'inbox', unreadEmails: 7 })],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(7, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a shared inbox, even when it sorts first', () => {
|
||||||
|
// Shared and group inboxes ship in the same `mailboxes` array. A plain
|
||||||
|
// `role === 'inbox'` lookup would badge somebody else's inbox on a
|
||||||
|
// delegated setup, so the store's canonical `!isShared` filter is required.
|
||||||
|
useEmailStore.setState({
|
||||||
|
mailboxes: [
|
||||||
|
mailbox({ id: 'shared', role: 'inbox', isShared: true, unreadEmails: 99 }),
|
||||||
|
mailbox({ id: 'mine', role: 'inbox', unreadEmails: 4 }),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(4, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badges zero when there is no inbox yet', () => {
|
||||||
|
useEmailStore.setState({ mailboxes: [] });
|
||||||
|
|
||||||
|
render(<FaviconBadge />);
|
||||||
|
|
||||||
|
expect(useFaviconBadgeMock).toHaveBeenCalledWith(0, true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEmailStore } from "@/stores/email-store";
|
||||||
|
import { useSettingsStore } from "@/stores/settings-store";
|
||||||
|
import { useFaviconBadge } from "@/hooks/use-favicon-badge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Badges the browser-tab favicon with the inbox unread count, so new mail is
|
||||||
|
* visible without focusing the tab. See issue #560.
|
||||||
|
*
|
||||||
|
* Opt-out via the `faviconUnreadBadge` setting (Settings -> Appearance); on by
|
||||||
|
* default.
|
||||||
|
*
|
||||||
|
* Mounted in the root layout rather than on the mail route: the badge belongs
|
||||||
|
* to the tab, not to a page. Mounting it on the mail page unmounted it — and so
|
||||||
|
* cleared the badge, and flickered the icon — on every hop to /settings,
|
||||||
|
* /calendar or /contacts.
|
||||||
|
*
|
||||||
|
* Renders nothing.
|
||||||
|
*/
|
||||||
|
export function FaviconBadge() {
|
||||||
|
// The store's canonical inbox selector. `role === 'inbox'` alone is not
|
||||||
|
// enough: shared and group inboxes ship in the same `mailboxes` array, so on
|
||||||
|
// a delegated setup the first match can be somebody else's inbox.
|
||||||
|
const inboxUnread = useEmailStore(
|
||||||
|
(s) => s.mailboxes.find((m) => m.role === "inbox" && !m.isShared)?.unreadEmails ?? 0,
|
||||||
|
);
|
||||||
|
const enabled = useSettingsStore((s) => s.faviconUnreadBadge);
|
||||||
|
|
||||||
|
useFaviconBadge(inboxUnread, enabled);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -118,7 +118,7 @@ function MailLayoutPreview({
|
|||||||
export function LayoutSettings() {
|
export function LayoutSettings() {
|
||||||
const t = useTranslations('settings.appearance');
|
const t = useTranslations('settings.appearance');
|
||||||
const tEmail = useTranslations('settings.email_behavior');
|
const tEmail = useTranslations('settings.email_behavior');
|
||||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, enableAllMailView, allMailFolderIds, enableCrossUnreadView, enableCrossStarredView, enableCrossAllView, colorfulSidebarIcons, tintListRowsByTag, showFolderTotalCount, faviconUnreadBadge, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||||
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
const { isSettingLocked, isSettingHidden, isFeatureEnabled } = usePolicyStore();
|
||||||
const accounts = useAccountStore(s => s.accounts);
|
const accounts = useAccountStore(s => s.accounts);
|
||||||
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
const activeAccountId = useAccountStore(s => s.activeAccountId);
|
||||||
@@ -231,6 +231,13 @@ export function LayoutSettings() {
|
|||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
|
||||||
|
<SettingItem label={t('favicon_unread_badge.label')} description={t('favicon_unread_badge.description')}>
|
||||||
|
<ToggleSwitch
|
||||||
|
checked={faviconUnreadBadge}
|
||||||
|
onChange={(checked) => updateSetting('faviconUnreadBadge', checked)}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
|
||||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||||
<SettingItem
|
<SettingItem
|
||||||
label={t('unified_mailbox.label')}
|
label={t('unified_mailbox.label')}
|
||||||
|
|||||||
@@ -0,0 +1,559 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import { render, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import { useFaviconBadge } from '@/hooks/use-favicon-badge';
|
||||||
|
import { renderBadgedFavicon } from '@/lib/favicon-badge';
|
||||||
|
|
||||||
|
vi.mock('@/lib/favicon-badge', () => ({
|
||||||
|
renderBadgedFavicon: vi.fn((_source: string, count: number) =>
|
||||||
|
count > 0 ? `data:image/svg+xml,BADGED-${count}` : null,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const renderBadgedFaviconMock = vi.mocked(renderBadgedFavicon);
|
||||||
|
|
||||||
|
const ORIGINAL_HREF = '/branding/Bulwark_Favicon.svg';
|
||||||
|
|
||||||
|
/** The link the hook owns: the only one it may ever touch. */
|
||||||
|
function badgeLink(): HTMLLinkElement | null {
|
||||||
|
return document.querySelector<HTMLLinkElement>('link[data-favicon-badge]');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The base icon link the page (or React) rendered: must survive untouched. */
|
||||||
|
function baseLinks(): HTMLLinkElement[] {
|
||||||
|
return Array.from(
|
||||||
|
document.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]:not([data-favicon-badge])'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Every icon link in <head>, in document order. The browser honours the last. */
|
||||||
|
function iconLinks(): HTMLLinkElement[] {
|
||||||
|
return Array.from(document.head.querySelectorAll<HTMLLinkElement>('link[rel~="icon"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastIconLink(): HTMLLinkElement | null {
|
||||||
|
return iconLinks().at(-1) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Next/React does on a client-side navigation: it re-hoists its metadata
|
||||||
|
* icon link into <head>, appending a *fresh* node after everything already
|
||||||
|
* there — including our badge link.
|
||||||
|
*/
|
||||||
|
function rehoistBaseIcon(href = ORIGINAL_HREF): HTMLLinkElement {
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
link.href = href;
|
||||||
|
document.head.appendChild(link);
|
||||||
|
return link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drains microtasks (MutationObserver callbacks) and one macrotask. */
|
||||||
|
async function settle(): Promise<void> {
|
||||||
|
for (let i = 0; i < 20; i++) await Promise.resolve();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgResponse(body = '<svg viewBox="0 0 1000 1000"/>') {
|
||||||
|
return new Response(body, { status: 200, headers: { 'content-type': 'image/svg+xml' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badger({ count }: { count: number }) {
|
||||||
|
useFaviconBadge(count);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
document.head.innerHTML = `<link rel="icon" href="${ORIGINAL_HREF}">`;
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => svgResponse()));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// document.head outlives every test, so a spy on it that a failing assertion
|
||||||
|
// never got to restore would leak into the next test's counts.
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useFaviconBadge', () => {
|
||||||
|
it('appends its own badged icon link when the count is positive', async () => {
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
expect(badgeLink()!.getAttribute('type')).toBe('image/svg+xml');
|
||||||
|
// Last-declared icon wins, so ours must be last in <head>.
|
||||||
|
expect(document.head.lastElementChild).toBe(badgeLink());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never removes or mutates an icon link it did not create', async () => {
|
||||||
|
const before = baseLinks()[0];
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
expect(before.isConnected).toBe(true);
|
||||||
|
expect(before.getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
expect(baseLinks()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves every icon link it did not create intact, including non-SVG fallbacks', async () => {
|
||||||
|
document.head.innerHTML =
|
||||||
|
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
|
||||||
|
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
|
||||||
|
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
const survivors = baseLinks();
|
||||||
|
expect(survivors).toHaveLength(2);
|
||||||
|
expect(survivors[0].getAttribute('href')).toBe('/a.svg');
|
||||||
|
expect(survivors[0].getAttribute('type')).toBe('image/svg+xml');
|
||||||
|
expect(survivors[1].getAttribute('href')).toBe('/a.png');
|
||||||
|
expect(survivors[1].getAttribute('type')).toBe('image/png');
|
||||||
|
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when React owns the icon link and its subtree is deleted', async () => {
|
||||||
|
// React 19 hoists <link rel="icon"> into <head> and keeps a fiber pointing at
|
||||||
|
// that DOM node. Removing it out from under React makes the commit phase throw
|
||||||
|
// "Cannot read properties of null (reading 'removeChild')" when the fiber is
|
||||||
|
// later deleted. The hook must therefore never touch a node it did not create.
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
|
||||||
|
<Badger count={3} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
// The React-owned node is still there, untouched.
|
||||||
|
const reactOwned = baseLinks();
|
||||||
|
expect(reactOwned).toHaveLength(1);
|
||||||
|
expect(reactOwned[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
|
||||||
|
expect(() => unmount()).not.toThrow();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces its own link rather than mutating its href', async () => {
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
const first = badgeLink();
|
||||||
|
|
||||||
|
rerender({ n: 4 });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-4');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Firefox ignores an in-place href change on the favicon link.
|
||||||
|
expect(badgeLink()).not.toBe(first);
|
||||||
|
expect(first!.isConnected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the badge by inserting a fresh link carrying the base href, not by removing its own', async () => {
|
||||||
|
// The field bug: Firefox does not re-evaluate the favicon when an icon link
|
||||||
|
// is *removed* — a removal is not an insertion, so it keeps painting the
|
||||||
|
// last icon it was handed and the stale "99+" badge sticks until a hard
|
||||||
|
// reload. Clearing must therefore be an insertion: our own link is replaced
|
||||||
|
// by a brand-new node carrying the original base href.
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
const badged = badgeLink()!;
|
||||||
|
|
||||||
|
rerender({ n: 0 });
|
||||||
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||||
|
|
||||||
|
const restored = badgeLink()!;
|
||||||
|
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
|
||||||
|
expect(badged.isConnected).toBe(false);
|
||||||
|
expect(restored.getAttribute('type')).toBe('image/svg+xml');
|
||||||
|
expect(lastIconLink()).toBe(restored);
|
||||||
|
|
||||||
|
// And the base link the page rendered is still untouched.
|
||||||
|
const survivors = baseLinks();
|
||||||
|
expect(survivors).toHaveLength(1);
|
||||||
|
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts the base-href link exactly once while the count stays at zero', async () => {
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
||||||
|
const ownAppends = () =>
|
||||||
|
appendSpy.mock.calls.filter(
|
||||||
|
([node]) => node instanceof Element && node.matches('link[data-favicon-badge]'),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
rerender({ n: 0 });
|
||||||
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||||
|
const restored = badgeLink()!;
|
||||||
|
expect(ownAppends()).toBe(1);
|
||||||
|
|
||||||
|
// Neither further renders at the same count nor unrelated <head> churn (an
|
||||||
|
// observer tick) may re-insert it: no remove/append thrash on every tick.
|
||||||
|
rerender({ n: 0 });
|
||||||
|
rerender({ n: 0 });
|
||||||
|
document.head.appendChild(document.createElement('meta'));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(ownAppends()).toBe(1);
|
||||||
|
expect(badgeLink()).toBe(restored);
|
||||||
|
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
|
||||||
|
appendSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its own link last even while it is only carrying the base href', async () => {
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
rerender({ n: 0 });
|
||||||
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||||
|
const own = badgeLink()!;
|
||||||
|
|
||||||
|
rehoistBaseIcon();
|
||||||
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||||
|
expect(badgeLink()).toBe(own); // moved, not recreated
|
||||||
|
});
|
||||||
|
|
||||||
|
it('badges again with a fresh insertion when the count leaves zero', async () => {
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
rerender({ n: 0 });
|
||||||
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||||
|
const cleared = badgeLink()!;
|
||||||
|
|
||||||
|
rerender({ n: 7 });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-7');
|
||||||
|
});
|
||||||
|
expect(badgeLink()).not.toBe(cleared);
|
||||||
|
expect(cleared.isConnected).toBe(false);
|
||||||
|
expect(lastIconLink()).toBe(badgeLink());
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
||||||
|
expect(baseLinks()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes only its own link on unmount', async () => {
|
||||||
|
const { unmount } = renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
expect(baseLinks()).toHaveLength(1);
|
||||||
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch, and adds no link, while the count is zero', async () => {
|
||||||
|
renderHook(() => useFaviconBadge(0));
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
expect(baseLinks()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the icon alone when the base is not SVG', async () => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(async () => new Response('binary', { headers: { 'content-type': 'image/png' } })),
|
||||||
|
);
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
|
||||||
|
// Assert on the observable end state, not merely on the href being
|
||||||
|
// unchanged: the href is also unchanged *before* the catch block runs,
|
||||||
|
// so a href-only assertion would pass even if the code went on to swap
|
||||||
|
// the icon a tick later. The renderer must never be reached.
|
||||||
|
await waitFor(() => expect(renderBadgedFaviconMock).not.toHaveBeenCalled());
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the icon alone when the base fetch fails', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 })));
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does nothing when there is no icon link to read', async () => {
|
||||||
|
document.head.innerHTML = '';
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await Promise.resolve(); // let any deferred async work start
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the base icon exactly once under StrictMode and rapid count changes', async () => {
|
||||||
|
// StrictMode double-invokes effects, and a count change while the fetch is in
|
||||||
|
// flight re-runs the effect: neither may issue a second request.
|
||||||
|
let resolveFetch: (response: Response) => void = () => {};
|
||||||
|
const inFlight = new Promise<Response>((resolve) => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
});
|
||||||
|
vi.stubGlobal(
|
||||||
|
'fetch',
|
||||||
|
vi.fn(() => inFlight),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
|
||||||
|
initialProps: { n: 1 },
|
||||||
|
wrapper: StrictMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ n: 2 });
|
||||||
|
rerender({ n: 5 });
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveFetch(svgResponse());
|
||||||
|
await waitFor(() => {
|
||||||
|
// The badge lands on the latest count, not the one in flight at fetch time.
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-5');
|
||||||
|
});
|
||||||
|
|
||||||
|
rerender({ n: 6 });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-6');
|
||||||
|
});
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-render the badge when the count is unchanged', async () => {
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), {
|
||||||
|
initialProps: { n: 3 },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
const settled = badgeLink();
|
||||||
|
renderBadgedFaviconMock.mockClear();
|
||||||
|
rerender({ n: 3 });
|
||||||
|
|
||||||
|
// The element identity alone is not evidence: `count` is the effect's only
|
||||||
|
// dependency, so React would skip the effect regardless. Assert the
|
||||||
|
// renderer was not invoked again — that is the behaviour under test.
|
||||||
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||||
|
expect(badgeLink()).toBe(settled);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when the setting is off', () => {
|
||||||
|
it('adds no link and does not fetch, however high the count', async () => {
|
||||||
|
renderHook(() => useFaviconBadge(3, false));
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
expect(baseLinks()).toHaveLength(1);
|
||||||
|
expect(baseLinks()[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a showing badge by inserting a fresh link carrying the base href', async () => {
|
||||||
|
// Same guarantee as the count-back-to-zero clear, and for the same reason:
|
||||||
|
// Firefox re-evaluates the favicon on an *insertion* and on nothing else.
|
||||||
|
// Turning the setting off by *removing* our link would leave the stale
|
||||||
|
// badge painted on the tab until a hard reload.
|
||||||
|
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
||||||
|
initialProps: { on: true },
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
const badged = badgeLink()!;
|
||||||
|
|
||||||
|
rerender({ on: false });
|
||||||
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||||
|
|
||||||
|
const restored = badgeLink()!;
|
||||||
|
expect(restored).not.toBe(badged); // a NEW node: an insertion, not an href swap
|
||||||
|
expect(badged.isConnected).toBe(false);
|
||||||
|
expect(restored.getAttribute('type')).toBe('image/svg+xml');
|
||||||
|
expect(lastIconLink()).toBe(restored);
|
||||||
|
|
||||||
|
// And the base link the page rendered is still untouched.
|
||||||
|
const survivors = baseLinks();
|
||||||
|
expect(survivors).toHaveLength(1);
|
||||||
|
expect(survivors[0].getAttribute('href')).toBe(ORIGINAL_HREF);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-badges with a fresh insertion when the setting is turned back on', async () => {
|
||||||
|
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
||||||
|
initialProps: { on: true },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
rerender({ on: false });
|
||||||
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||||
|
const cleared = badgeLink()!;
|
||||||
|
|
||||||
|
rerender({ on: true });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
expect(badgeLink()).not.toBe(cleared);
|
||||||
|
expect(cleared.isConnected).toBe(false);
|
||||||
|
expect(lastIconLink()).toBe(badgeLink());
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
||||||
|
expect(baseLinks()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps its base-href link last when React re-hoists its icon', async () => {
|
||||||
|
const { rerender } = renderHook(({ on }) => useFaviconBadge(3, on), {
|
||||||
|
initialProps: { on: true },
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
rerender({ on: false });
|
||||||
|
await waitFor(() => expect(badgeLink()!.getAttribute('href')).toBe(ORIGINAL_HREF));
|
||||||
|
const own = badgeLink()!;
|
||||||
|
|
||||||
|
rehoistBaseIcon();
|
||||||
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||||
|
expect(badgeLink()).toBe(own); // moved, not recreated
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('when React re-hoists its icon link on a client-side navigation', () => {
|
||||||
|
it('moves its own link back to the end so the badge keeps winning', async () => {
|
||||||
|
// The field bug: Inbox badges the tab, a hop to /calendar makes React
|
||||||
|
// re-insert its metadata <link rel="icon"> *after* ours, the base icon
|
||||||
|
// wins again and the badge vanishes.
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
expect(lastIconLink()).toBe(badgeLink());
|
||||||
|
|
||||||
|
const own = badgeLink()!;
|
||||||
|
const rehoisted = rehoistBaseIcon();
|
||||||
|
expect(lastIconLink()).toBe(rehoisted); // the badge is now outranked
|
||||||
|
|
||||||
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||||
|
expect(document.head.lastElementChild).toBe(own);
|
||||||
|
expect(badgeLink()).toBe(own); // moved, not recreated
|
||||||
|
expect(rehoisted.isConnected).toBe(true); // and React's node is untouched
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the badge without the count changing', async () => {
|
||||||
|
// The "never comes back" half of the bug. Navigating back to the inbox
|
||||||
|
// does not change the unread count, so nothing re-runs the count effect:
|
||||||
|
// the observer alone must put the badge back on top.
|
||||||
|
const { rerender } = renderHook(({ n }) => useFaviconBadge(n), { initialProps: { n: 3 } });
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
renderBadgedFaviconMock.mockClear();
|
||||||
|
rehoistBaseIcon();
|
||||||
|
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
|
||||||
|
|
||||||
|
rerender({ n: 3 }); // same count: no effect re-run to lean on
|
||||||
|
await settle();
|
||||||
|
|
||||||
|
expect(lastIconLink()).toBe(badgeLink());
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
expect(renderBadgedFaviconMock).not.toHaveBeenCalled();
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-applies the badge if its own link is removed entirely', async () => {
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
badgeLink()!.remove(); // React blows away part of <head>
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(badgeLink()!.getAttribute('href')).toBe('data:image/svg+xml,BADGED-3');
|
||||||
|
});
|
||||||
|
expect(lastIconLink()).toBe(badgeLink());
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1); // the base is still fetched only once
|
||||||
|
});
|
||||||
|
|
||||||
|
it('settles: re-appending its own link does not feed the observer a loop', async () => {
|
||||||
|
// Moving our link fires the observer again. If the move is not guarded by
|
||||||
|
// "am I already last?", that second run moves it again, for ever. Count
|
||||||
|
// the appends of *our* node: exactly one, and it must stop growing.
|
||||||
|
renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
const own = badgeLink()!;
|
||||||
|
|
||||||
|
const appendSpy = vi.spyOn(document.head, 'appendChild');
|
||||||
|
const ownAppends = () => appendSpy.mock.calls.filter(([node]) => node === own).length;
|
||||||
|
|
||||||
|
rehoistBaseIcon();
|
||||||
|
await waitFor(() => expect(lastIconLink()).toBe(own));
|
||||||
|
expect(ownAppends()).toBe(1);
|
||||||
|
|
||||||
|
await settle();
|
||||||
|
expect(ownAppends()).toBe(1); // the observer's own mutation is a no-op
|
||||||
|
expect(lastIconLink()).toBe(own);
|
||||||
|
// Base + re-hoisted base + exactly one badge: nothing was duplicated.
|
||||||
|
expect(iconLinks()).toHaveLength(3);
|
||||||
|
expect(document.querySelectorAll('link[data-favicon-badge]')).toHaveLength(1);
|
||||||
|
appendSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still never removes or mutates a link it did not create', async () => {
|
||||||
|
document.head.innerHTML =
|
||||||
|
`<link rel="icon" type="image/svg+xml" href="/a.svg">` +
|
||||||
|
`<link rel="icon" type="image/png" sizes="32x32" href="/a.png">`;
|
||||||
|
|
||||||
|
const { unmount } = render(
|
||||||
|
<>
|
||||||
|
<link rel="icon" type="image/svg+xml" href={ORIGINAL_HREF} />
|
||||||
|
<Badger count={3} />
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
const rehoisted = rehoistBaseIcon();
|
||||||
|
await waitFor(() => expect(lastIconLink()).toBe(badgeLink()));
|
||||||
|
|
||||||
|
const survivors = baseLinks();
|
||||||
|
expect(survivors).toHaveLength(4);
|
||||||
|
expect(survivors.map((l) => l.getAttribute('href'))).toEqual([
|
||||||
|
'/a.svg',
|
||||||
|
'/a.png',
|
||||||
|
ORIGINAL_HREF,
|
||||||
|
ORIGINAL_HREF,
|
||||||
|
]);
|
||||||
|
expect(survivors[1].getAttribute('type')).toBe('image/png');
|
||||||
|
expect(survivors[1].getAttribute('sizes')).toBe('32x32');
|
||||||
|
expect(rehoisted.isConnected).toBe(true);
|
||||||
|
|
||||||
|
// The React-owned node is still React's to delete.
|
||||||
|
expect(() => unmount()).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disconnects the observer on unmount and leaves nothing of its own behind', async () => {
|
||||||
|
const disconnect = vi.spyOn(MutationObserver.prototype, 'disconnect');
|
||||||
|
const { unmount } = renderHook(() => useFaviconBadge(3));
|
||||||
|
await waitFor(() => expect(badgeLink()).not.toBeNull());
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(disconnect).toHaveBeenCalled();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
|
||||||
|
// A post-unmount re-hoist must not resurrect the badge.
|
||||||
|
rehoistBaseIcon();
|
||||||
|
await settle();
|
||||||
|
expect(badgeLink()).toBeNull();
|
||||||
|
expect(baseLinks()).toHaveLength(2);
|
||||||
|
disconnect.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { renderBadgedFavicon } from '@/lib/favicon-badge';
|
||||||
|
import { debug } from '@/lib/debug';
|
||||||
|
|
||||||
|
// Our own link, and only ever our own. Next's metadata `icons` (app/(main)/
|
||||||
|
// layout.tsx) renders <link rel="icon"> through React, which hoists it into
|
||||||
|
// <head> and keeps a fiber pointing at that DOM node. Removing it out from
|
||||||
|
// under React leaves the fiber holding a detached node, and the next commit
|
||||||
|
// that deletes that fiber throws "Cannot read properties of null (reading
|
||||||
|
// 'removeChild')". So we never remove or mutate a node we did not create:
|
||||||
|
// instead we append an *extra* icon link, marked as ours. The last-declared
|
||||||
|
// icon wins in browsers, so ours overrides the base without deleting it.
|
||||||
|
//
|
||||||
|
// Ours is never removed to clear the badge, though — only on unmount. Firefox
|
||||||
|
// re-evaluates the favicon on an *insertion* and on nothing else: a removal
|
||||||
|
// leaves it painting the last icon it was handed, which is how a read inbox
|
||||||
|
// kept a stale "99+" in the tab. Clearing therefore re-inserts our link with
|
||||||
|
// the original base href in place of the badge (see `apply`).
|
||||||
|
const MARKER = 'data-favicon-badge';
|
||||||
|
const OWN_SELECTOR = `link[${MARKER}]`;
|
||||||
|
const ICON_SELECTOR = 'link[rel~="icon"]';
|
||||||
|
const BASE_SELECTOR = `${ICON_SELECTOR}:not([${MARKER}])`;
|
||||||
|
|
||||||
|
// Both the badged icon and the untouched base we fall back to are SVG: the hook
|
||||||
|
// disables itself unless the fetched base is served as image/svg+xml, so by the
|
||||||
|
// time either link exists that content type is a proven fact, not a guess.
|
||||||
|
const ICON_TYPE = 'image/svg+xml';
|
||||||
|
|
||||||
|
function removeOwnLink(): void {
|
||||||
|
document.querySelectorAll(OWN_SELECTOR).forEach((el) => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownLink(): HTMLLinkElement | null {
|
||||||
|
return document.head.querySelector<HTMLLinkElement>(OWN_SELECTOR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when ours is the last icon link in <head>, i.e. the one the browser uses. */
|
||||||
|
function isLastIconLink(link: HTMLLinkElement): boolean {
|
||||||
|
const icons = document.head.querySelectorAll<HTMLLinkElement>(ICON_SELECTOR);
|
||||||
|
return icons[icons.length - 1] === link;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a fresh icon link of ours, replacing any previous one of ours.
|
||||||
|
*
|
||||||
|
* Always a remove-then-append of a *new* node, never an href mutation: Firefox
|
||||||
|
* only re-evaluates the favicon when an icon link is inserted. It ignores an
|
||||||
|
* in-place href change, and — the count-back-to-zero bug — it equally ignores a
|
||||||
|
* removal, happily painting the last icon it was handed. So even *clearing* the
|
||||||
|
* badge is done by inserting: see `apply`, which re-inserts our link carrying
|
||||||
|
* the original base href rather than deleting it.
|
||||||
|
*/
|
||||||
|
function setOwnLink(href: string): void {
|
||||||
|
removeOwnLink();
|
||||||
|
const link = document.createElement('link');
|
||||||
|
link.rel = 'icon';
|
||||||
|
link.type = ICON_TYPE;
|
||||||
|
link.href = href;
|
||||||
|
link.setAttribute(MARKER, '');
|
||||||
|
document.head.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws `count` as a badge on the browser-tab favicon, unless `enabled` is
|
||||||
|
* false (the `faviconUnreadBadge` setting).
|
||||||
|
*
|
||||||
|
* The base icon is read from the rendered <link rel="icon">, so admin and
|
||||||
|
* per-domain branding overrides (configManager `faviconUrl`) are respected
|
||||||
|
* without plumbing config to the client.
|
||||||
|
*
|
||||||
|
* Every failure — no icon link, a fetch error, a non-SVG base, unparseable
|
||||||
|
* source — leaves the existing favicon untouched.
|
||||||
|
*/
|
||||||
|
export function useFaviconBadge(count: number, enabled = true): void {
|
||||||
|
// Disabled is just "nothing to show", i.e. exactly a count of zero, so it
|
||||||
|
// rides the same paths: no fetch while we have never badged, and — the part
|
||||||
|
// that matters — clearing an *existing* badge by inserting a fresh link
|
||||||
|
// carrying the base href rather than removing ours, which Firefox would
|
||||||
|
// ignore (see `apply`). Switching the setting off therefore restores the
|
||||||
|
// plain icon immediately, with no reload.
|
||||||
|
const effectiveCount = enabled ? count : 0;
|
||||||
|
const baseSource = useRef<string | null>(null);
|
||||||
|
const baseHref = useRef<string | null>(null);
|
||||||
|
// The href our own link currently carries, and the hook's whole state machine:
|
||||||
|
// null -> nothing of ours is in <head> (we have never badged)
|
||||||
|
// baseHref -> ours is in <head>, showing the unbadged base icon
|
||||||
|
// a data: URL -> ours is in <head>, showing the badge
|
||||||
|
const appliedHref = useRef<string | null>(null);
|
||||||
|
const disabled = useRef(false);
|
||||||
|
const fetchStarted = useRef(false);
|
||||||
|
const unmounted = useRef(false);
|
||||||
|
const latestCount = useRef(effectiveCount);
|
||||||
|
latestCount.current = effectiveCount;
|
||||||
|
|
||||||
|
// Declared before the badge effect so that on a StrictMode remount it runs
|
||||||
|
// first and clears `unmounted` before the badge effect reads it.
|
||||||
|
useEffect(() => {
|
||||||
|
unmounted.current = false;
|
||||||
|
return () => {
|
||||||
|
unmounted.current = true;
|
||||||
|
// Restore the server-rendered favicon by removing our override. Nothing
|
||||||
|
// else in <head> is ours to touch.
|
||||||
|
removeOwnLink();
|
||||||
|
appliedHref.current = null;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Reads the refs rather than a closure over `count`, so that the reply to an
|
||||||
|
// in-flight fetch — and the MutationObserver below, which outlives any single
|
||||||
|
// render — lands on the newest count, not the one that started it.
|
||||||
|
const apply = useCallback(() => {
|
||||||
|
if (unmounted.current || disabled.current) return;
|
||||||
|
|
||||||
|
const current = latestCount.current;
|
||||||
|
if (current <= 0) {
|
||||||
|
// Clearing the badge is an *insertion*, not a removal.
|
||||||
|
//
|
||||||
|
// The field bug: with 133 unread the tab showed "99+", the user read
|
||||||
|
// everything, the store went to 0 — and Firefox kept painting "99+" until
|
||||||
|
// a hard reload. Removing our link is not an insertion, and Firefox only
|
||||||
|
// re-evaluates the favicon on an insertion; a removal leaves it painting
|
||||||
|
// the last icon it was handed. So instead of deleting our link we replace
|
||||||
|
// it with a fresh one carrying the *original* base href: same pixels as
|
||||||
|
// the untouched base link below it, but handed to the browser as a new
|
||||||
|
// icon, which it does repaint.
|
||||||
|
//
|
||||||
|
// Never badged (`appliedHref` still null)? Then nothing of ours is in
|
||||||
|
// <head> and nothing should be: a fully-read inbox adds no link at all.
|
||||||
|
const base = baseHref.current;
|
||||||
|
if (appliedHref.current === null || base === null) return;
|
||||||
|
if (appliedHref.current === base && ownLink()) return; // already showing the base: no thrash
|
||||||
|
|
||||||
|
setOwnLink(base);
|
||||||
|
appliedHref.current = base;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = baseSource.current;
|
||||||
|
if (source === null) return; // still fetching; the fetch will call back
|
||||||
|
|
||||||
|
const next = renderBadgedFavicon(source, current);
|
||||||
|
if (!next) return;
|
||||||
|
if (next === appliedHref.current && ownLink()) return;
|
||||||
|
|
||||||
|
setOwnLink(next);
|
||||||
|
appliedHref.current = next;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (disabled.current) return;
|
||||||
|
|
||||||
|
// Nothing to show and nothing applied: do not even fetch. A fully-read
|
||||||
|
// inbox — or the setting switched off before we ever badged — should cost
|
||||||
|
// no request.
|
||||||
|
if (effectiveCount <= 0 && baseSource.current === null && !fetchStarted.current) return;
|
||||||
|
|
||||||
|
// The base is fetched at most once, ever. Without this guard a StrictMode
|
||||||
|
// double-invoke issues two requests, and any count change while the fetch
|
||||||
|
// is in flight issues another.
|
||||||
|
if (baseSource.current !== null || fetchStarted.current) {
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one and only read of the base link. Its href is both what we fetch the
|
||||||
|
// source from and what we hand back to the browser when the badge clears.
|
||||||
|
const link = document.querySelector<HTMLLinkElement>(BASE_SELECTOR);
|
||||||
|
const href = link?.getAttribute('href');
|
||||||
|
if (!href) {
|
||||||
|
disabled.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
baseHref.current = href;
|
||||||
|
fetchStarted.current = true;
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(href);
|
||||||
|
if (!response.ok) throw new Error(`favicon fetch failed: ${response.status}`);
|
||||||
|
|
||||||
|
const contentType = response.headers.get('content-type') ?? '';
|
||||||
|
if (!contentType.includes('image/svg+xml')) {
|
||||||
|
throw new Error(`favicon is not SVG: ${contentType || 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSource.current = await response.text();
|
||||||
|
apply();
|
||||||
|
} catch (error) {
|
||||||
|
disabled.current = true;
|
||||||
|
debug.log('[favicon-badge] disabled:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [effectiveCount, apply]);
|
||||||
|
|
||||||
|
// Keep ours the last icon link in <head>.
|
||||||
|
//
|
||||||
|
// On a client-side navigation (Inbox -> Calendar) Next re-hoists the metadata
|
||||||
|
// <link rel="icon"> from app/(main)/layout.tsx into <head>. The re-inserted
|
||||||
|
// node lands *after* our badge link, the last-declared icon wins, and the
|
||||||
|
// badge vanishes. Coming back to the inbox did not bring it back either: the
|
||||||
|
// count is unchanged, so the effect above never re-ran and our link just sat
|
||||||
|
// there outranked. Watching <head> fixes both halves at once.
|
||||||
|
//
|
||||||
|
// Termination: moving our own link is itself a <head> mutation, so it feeds
|
||||||
|
// the observer a fresh record. The guard is `isLastIconLink` — on that second
|
||||||
|
// run ours *is* last, so we do nothing and the cascade stops. One move per
|
||||||
|
// foreign insertion, never two.
|
||||||
|
const keepOwnLinkLast = useCallback(() => {
|
||||||
|
if (unmounted.current || disabled.current) return;
|
||||||
|
// Ours must stay last in *both* states — badged, and showing the base href
|
||||||
|
// after a clear (`appliedHref` is only null when we have never badged, and
|
||||||
|
// then nothing of ours is in <head> to keep last). Gating this on the count
|
||||||
|
// instead would strand our base-href link behind a re-hoisted React icon,
|
||||||
|
// and the next badge would have to fight its way back on top.
|
||||||
|
if (appliedHref.current === null) return;
|
||||||
|
|
||||||
|
const own = ownLink();
|
||||||
|
if (!own) {
|
||||||
|
// React blew our link away with the rest of the head: re-apply from scratch.
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isLastIconLink(own)) return;
|
||||||
|
|
||||||
|
// Re-appending *our own* element is the only mutation we ever make; a node
|
||||||
|
// we did not create is never removed, moved or touched (see above).
|
||||||
|
document.head.appendChild(own);
|
||||||
|
}, [apply]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new MutationObserver(keepOwnLinkLast);
|
||||||
|
observer.observe(document.head, { childList: true });
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [keepOwnLinkLast]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
|
||||||
|
|
||||||
|
const BASE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clip1"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clip1)"><rect width="1000" height="1000" fill="#123456"/></g></svg>`;
|
||||||
|
|
||||||
|
function decode(dataUrl: string): string {
|
||||||
|
return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The badge band: the last <rect> the renderer appends, identified by its fill. */
|
||||||
|
function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
|
||||||
|
const match =
|
||||||
|
/<rect[^>]*\bx="(-?[\d.]+)"[^>]*\by="(-?[\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*fill="#ffffff"/.exec(
|
||||||
|
svg,
|
||||||
|
);
|
||||||
|
expect(match).not.toBeNull();
|
||||||
|
const [, x, y, w, h, rx] = match!.map(Number);
|
||||||
|
return { x, y, w, h, rx };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fontSize(svg: string): number {
|
||||||
|
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
|
||||||
|
const [minX, minY, width, height] = /viewBox="([^"]+)"/
|
||||||
|
.exec(svg)![1]
|
||||||
|
.trim()
|
||||||
|
.split(/[\s,]+/)
|
||||||
|
.map(Number);
|
||||||
|
return { minX, minY, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('formatBadgeCount', () => {
|
||||||
|
it('returns an empty string for zero and below', () => {
|
||||||
|
expect(formatBadgeCount(0)).toBe('');
|
||||||
|
expect(formatBadgeCount(-3)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the count verbatim from 1 to 99', () => {
|
||||||
|
expect(formatBadgeCount(1)).toBe('1');
|
||||||
|
expect(formatBadgeCount(9)).toBe('9');
|
||||||
|
expect(formatBadgeCount(47)).toBe('47');
|
||||||
|
expect(formatBadgeCount(99)).toBe('99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps at 99+ above 99', () => {
|
||||||
|
// Gmail caps at 20; matching it was tried and reverted. A lower cap means a
|
||||||
|
// typical inbox needs three glyphs almost always, and three glyphs do not
|
||||||
|
// fit at the full font size — so "99+" rendered permanently smaller than a
|
||||||
|
// real two-digit count would have.
|
||||||
|
expect(formatBadgeCount(100)).toBe('99+');
|
||||||
|
expect(formatBadgeCount(133)).toBe('99+');
|
||||||
|
expect(formatBadgeCount(1000)).toBe('99+');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty string for non-finite input', () => {
|
||||||
|
expect(formatBadgeCount(Number.NaN)).toBe('');
|
||||||
|
expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderBadgedFavicon', () => {
|
||||||
|
it('returns null when the count is zero', () => {
|
||||||
|
expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the source is not SVG', () => {
|
||||||
|
expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the root element is not <svg>', () => {
|
||||||
|
expect(renderBadgedFavicon('<html><body/></html>', 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the root has no viewBox', () => {
|
||||||
|
const noViewBox = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>`;
|
||||||
|
expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a percent-encoded svg data URL', () => {
|
||||||
|
const url = renderBadgedFavicon(BASE_SVG, 3);
|
||||||
|
expect(url).not.toBeNull();
|
||||||
|
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a badge band and the count text', () => {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||||
|
expect(svg).toContain('<rect');
|
||||||
|
expect(svg).toContain('>3<');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 99+ for large counts', () => {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
||||||
|
expect(svg).toContain('>99+<');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the base artwork and its clipPath id', () => {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||||
|
expect(svg).toContain('id="_clip1"');
|
||||||
|
expect(svg).toContain('#123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||||
|
expect(svg).toContain('width="16"');
|
||||||
|
expect(svg).toContain('height="16"');
|
||||||
|
expect(svg).toContain('viewBox="0 0 1000 1000"');
|
||||||
|
expect(svg).not.toContain('1000pt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws a white band with black digits, so the count stays legible over any base icon', () => {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||||
|
expect(svg).toMatch(/<rect[^>]*fill="#ffffff"/);
|
||||||
|
expect(svg).toMatch(/<text[^>]*fill="#000000"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shrinks the font as the label grows so three glyphs still fit', () => {
|
||||||
|
const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||||
|
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
||||||
|
expect(fontSize(three)).toBeLessThan(fontSize(one));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null rather than throwing when the source contains a lone surrogate', () => {
|
||||||
|
const bad = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>abc\uD800def</title></svg>`;
|
||||||
|
expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
|
||||||
|
expect(renderBadgedFavicon(bad, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sizes the band to the label, and only "99+" fills the full icon width', () => {
|
||||||
|
// The box is only as wide as its digits need — "5" must not squat on as much
|
||||||
|
// white as "99+". It is never wider than the icon, and three glyphs, whose
|
||||||
|
// font is budgeted against the full span, grow to exactly fill it.
|
||||||
|
const w = (count: number) => band(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
|
||||||
|
expect(w(7)).toBeLessThan(w(47));
|
||||||
|
expect(w(47)).toBeLessThan(w(250));
|
||||||
|
expect(w(250)).toBeCloseTo(1000, 5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('matches the geometry measured from Gmail\'s 16px favicon', () => {
|
||||||
|
// Ground truth, measured pixel-by-pixel off Gmail's tab icon and scaled to a
|
||||||
|
// 0 0 1000 1000 viewBox: band 10/16 of the icon (0.625), flush to the bottom
|
||||||
|
// edge, corners rounded by 0.1h, width fitted to the label, anchored right.
|
||||||
|
// Gmail's own single-digit badge sits hard right in a box about a third of
|
||||||
|
// the icon wide, so the box grows leftwards from the corner.
|
||||||
|
// w = label.length * 0.6 * font + 2 * 0.04 * 1000, x = 1000 - w.
|
||||||
|
const expected: Record<string, { x: number; w: number; font: number }> = {
|
||||||
|
'5': { x: 554, w: 446, font: 610 }, // textW = 1 * 0.6 * 610 = 366
|
||||||
|
'15': { x: 188, w: 812, font: 610 }, // textW = 2 * 0.6 * 610 = 732
|
||||||
|
'250': { x: 0, w: 1000, font: 920 / 1.8 }, // "99+": font = (1000 - 80) / (3 * 0.6)
|
||||||
|
};
|
||||||
|
for (const [count, want] of Object.entries(expected)) {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
|
||||||
|
const { x, y, w, h, rx } = band(svg);
|
||||||
|
expect(x).toBeCloseTo(want.x, 5);
|
||||||
|
expect(w).toBeCloseTo(want.w, 5);
|
||||||
|
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
|
||||||
|
expect(y).toBeCloseTo(375, 5);
|
||||||
|
expect(h).toBeCloseTo(625, 5);
|
||||||
|
expect(rx).toBeCloseTo(62.5, 5);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('anchors the band to the right edge, including on a negative-origin viewBox', () => {
|
||||||
|
// Corner-anchored, not centred: the box grows leftwards from the bottom-right
|
||||||
|
// corner, so its right edge sits on minX + span whatever the label. Centring
|
||||||
|
// was rejected — at a single digit it lands under the middle of the mark.
|
||||||
|
const cases: [string, number, number][] = [
|
||||||
|
// [base svg, minX, span]
|
||||||
|
[BASE_SVG, 0, 1000],
|
||||||
|
[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24"><rect x="-4" y="-4" width="24" height="24" fill="#123456"/></svg>`, -4, 24],
|
||||||
|
];
|
||||||
|
for (const [svgSource, minX, span] of cases) {
|
||||||
|
for (const count of [7, 47, 250]) {
|
||||||
|
const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
|
||||||
|
expect(x + w).toBeCloseTo(minX + span, 5);
|
||||||
|
expect(x).toBeGreaterThanOrEqual(minX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
|
||||||
|
// The font is budgeted against the full icon span, not against the fitted
|
||||||
|
// box, so one or two glyphs always land at FONT_MAX; only three force a
|
||||||
|
// shrink — and their box then grows to fill the icon.
|
||||||
|
const FONT_MAX = 0.61 * 1000;
|
||||||
|
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
|
||||||
|
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
|
||||||
|
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
|
||||||
|
expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
|
||||||
|
expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
|
||||||
|
expect(fontSize(three)).toBeLessThan(FONT_MAX);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rounds the band corners slightly — neither an oval nor a hard square', () => {
|
||||||
|
// rx = h / 2 was the pill: at one digit it read as a circle, at two an oval,
|
||||||
|
// and "99+" was a smudge. rx = 0 is the other failure: Gmail's corners carry
|
||||||
|
// a visible ~1px round at 16px. Guard against a silent revert to either.
|
||||||
|
for (const count of [7, 47, 250]) {
|
||||||
|
const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
|
||||||
|
expect(rx).toBeCloseTo(0.1 * h, 5);
|
||||||
|
expect(rx).toBeGreaterThan(0);
|
||||||
|
expect(rx).toBeLessThan(h / 2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('draws the digits at font-weight 500, in both the attribute and the style', () => {
|
||||||
|
// 700 read visibly heavier than Gmail's equivalent badge.
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||||
|
expect(svg).toMatch(/<text[^>]*font-weight="500"/);
|
||||||
|
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*500/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the badge band entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
|
||||||
|
// The band is flush to the bottom and, at three glyphs, to the left and
|
||||||
|
// right edges too — but it must never overflow any of them.
|
||||||
|
for (const count of [7, 47, 250]) {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
|
||||||
|
const { x, y, w, h } = band(svg);
|
||||||
|
expect(x).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(x + w).toBeLessThanOrEqual(1000);
|
||||||
|
expect(y + h).toBeLessThanOrEqual(1000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
|
||||||
|
// negative number: dropping `minX +` from the anchoring passed it. Anchor
|
||||||
|
// against a viewBox whose origin is negative, where the band's own x is
|
||||||
|
// legitimately negative, so the offset is genuinely pinned.
|
||||||
|
it('anchors the band to the viewBox origin, including a negative origin', () => {
|
||||||
|
const NEGATIVE_ORIGIN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-40 -40 240 240"><rect x="-40" y="-40" width="240" height="240" fill="#123456"/></svg>`;
|
||||||
|
for (const count of [7, 47, 250]) {
|
||||||
|
const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
|
||||||
|
const { x, y, w, h } = band(svg);
|
||||||
|
expect(x).toBeGreaterThanOrEqual(-40);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(-40);
|
||||||
|
expect(x + w).toBeLessThanOrEqual(200);
|
||||||
|
expect(y + h).toBeLessThanOrEqual(200);
|
||||||
|
// Anchored to the bottom-right: in a viewBox running from -40 to 200, the
|
||||||
|
// band's bottom edge and its right edge both sit well past the midpoint.
|
||||||
|
expect(x + w).toBeGreaterThan(80);
|
||||||
|
expect(y + h).toBeGreaterThan(80);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fits the label inside the band, with padding, for every label length', () => {
|
||||||
|
// The core band invariant: textW + 2 * pad <= w, where the glyph advance and
|
||||||
|
// padding are the renderer's own published constants. PAD_FACTOR is a
|
||||||
|
// fraction of the icon span, not of the fitted box, so the padding is the
|
||||||
|
// same at every label length.
|
||||||
|
const GLYPH_ADV = 0.6;
|
||||||
|
const PAD_FACTOR = 0.04;
|
||||||
|
for (const count of [7, 47, 250]) {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
|
||||||
|
const { w } = band(svg);
|
||||||
|
const label = count > 99 ? '99+' : String(count);
|
||||||
|
const textW = label.length * GLYPH_ADV * fontSize(svg);
|
||||||
|
const pad = PAD_FACTOR * 1000;
|
||||||
|
expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
|
||||||
|
const url = renderBadgedFavicon(BASE_SVG, 3)!;
|
||||||
|
// encodeURI leaves "#" bare, which the browser reads as a fragment
|
||||||
|
// delimiter: everything after the first colour would be silently dropped.
|
||||||
|
expect(url).toContain('%23');
|
||||||
|
expect(url).not.toContain('#');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty label, and no badge, for a fractional count below one', () => {
|
||||||
|
expect(formatBadgeCount(0.5)).toBe('');
|
||||||
|
expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null when the root svg has no SVG namespace', () => {
|
||||||
|
// Non-null but unrenderable: a data URL built from this would show nothing.
|
||||||
|
const noNs = `<svg viewBox="0 0 100 100"><rect width="100" height="100"/></svg>`;
|
||||||
|
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
|
||||||
|
// Presentation attributes lose to any CSS rule in the document. A branded
|
||||||
|
// base carrying `rect { fill: #db2d54 }` would otherwise paint the band red
|
||||||
|
// and the digits red — exactly what the white band exists to prevent.
|
||||||
|
const STYLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><style>rect{fill:#db2d54}text{fill:#db2d54}</style><rect width="1000" height="1000"/></svg>`;
|
||||||
|
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
|
||||||
|
expect(svg).toMatch(/<rect[^>]*style="[^"]*fill:\s*#ffffff/);
|
||||||
|
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#000000/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
|
||||||
|
// The base may be an admin-uploaded file, which upstream serves under a
|
||||||
|
// sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
|
||||||
|
// same-origin data: URL would un-fence it, so sanitise before serialising.
|
||||||
|
const HOSTILE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" onload="alert(1)"><script>alert(2)</script><foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject><rect width="100" height="100" onclick="alert(3)" ONMOUSEOVER="alert(4)" fill="#123456"/></svg>`;
|
||||||
|
const url = renderBadgedFavicon(HOSTILE, 3)!;
|
||||||
|
expect(url).not.toBeNull();
|
||||||
|
const svg = decode(url);
|
||||||
|
expect(svg).not.toContain('<script');
|
||||||
|
expect(svg).not.toContain('foreignObject');
|
||||||
|
expect(svg.toLowerCase()).not.toContain('onload');
|
||||||
|
expect(svg.toLowerCase()).not.toContain('onclick');
|
||||||
|
expect(svg.toLowerCase()).not.toContain('onmouseover');
|
||||||
|
expect(svg).not.toContain('alert');
|
||||||
|
// The legitimate artwork survives.
|
||||||
|
expect(svg).toContain('#123456');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalises a non-square viewBox to a square, so the badge stays legible', () => {
|
||||||
|
// A 100x20 wordmark: span = min(w, h) = 20 previously produced a ~2px-tall
|
||||||
|
// smudge on a 16px icon. Squaring the viewBox first sizes the badge against
|
||||||
|
// the rendered box instead.
|
||||||
|
const WORDMARK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><rect width="100" height="20" fill="#123456"/></svg>`;
|
||||||
|
const svg = decode(renderBadgedFavicon(WORDMARK, 42)!);
|
||||||
|
|
||||||
|
const vb = viewBoxOf(svg);
|
||||||
|
expect(vb.width).toBe(100);
|
||||||
|
expect(vb.height).toBe(100);
|
||||||
|
expect(vb.minX).toBe(0);
|
||||||
|
expect(vb.minY).toBe(-40); // centred: (100 - 20) / 2 above and below
|
||||||
|
|
||||||
|
const { x, y, w, h } = band(svg);
|
||||||
|
// Sized against the square side (100), not the 20-unit short axis.
|
||||||
|
expect(h).toBeCloseTo(0.625 * 100, 5);
|
||||||
|
// Two glyphs at FONT_MAX (61) plus padding: 2 * 0.6 * 61 + 2 * 4 = 81.2,
|
||||||
|
// anchored to the right of the squared span.
|
||||||
|
expect(w).toBeCloseTo(81.2, 5);
|
||||||
|
expect(x + w).toBeCloseTo(100, 5);
|
||||||
|
// Still in bounds of the normalised viewBox.
|
||||||
|
expect(x).toBeGreaterThanOrEqual(vb.minX);
|
||||||
|
expect(y).toBeGreaterThanOrEqual(vb.minY);
|
||||||
|
expect(x + w).toBeLessThanOrEqual(vb.minX + vb.width);
|
||||||
|
expect(y + h).toBeLessThanOrEqual(vb.minY + vb.height);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a square viewBox untouched', () => {
|
||||||
|
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
|
||||||
|
expect(svg).toContain('viewBox="0 0 1000 1000"');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
|
||||||
|
// A neutral white band with black digits, rather than the conventional red
|
||||||
|
// badge. The band guarantees contrast for the count whatever the base icon
|
||||||
|
// looks like, which matters because `faviconUrl` is admin-overridable and may
|
||||||
|
// be any artwork. A coloured badge cannot make that guarantee: Bulwark's own
|
||||||
|
// icon is rgb(219,45,84), so a red badge sat red-on-red.
|
||||||
|
const BADGE_FILL = '#ffffff';
|
||||||
|
const BADGE_TEXT_FILL = '#000000';
|
||||||
|
const BADGE_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
|
||||||
|
|
||||||
|
// The badge is a Gmail-style band across the bottom of the icon, sized as a
|
||||||
|
// fraction of the icon's own coordinate space so it lands correctly whatever
|
||||||
|
// viewBox the base declares.
|
||||||
|
//
|
||||||
|
// The fractions below are not invented: they are measured, pixel-by-pixel, off
|
||||||
|
// Gmail's real 16x16 tab favicon, which is the badge users actually compare this
|
||||||
|
// one against. Gmail's band is 10 of 16 px tall (0.625 of the icon span), its
|
||||||
|
// digits have a cap height of 7 of 16 px (0.44, i.e. a font-size of ~0.61 span),
|
||||||
|
// it is flush — edge to edge, and to the bottom, with no inset margin — and its
|
||||||
|
// corners carry a slight round, about 1px at 16px, which is roughly 0.1 of the
|
||||||
|
// band height. Not square, and emphatically not h/2.
|
||||||
|
//
|
||||||
|
// The box is sized to the label and centred, as Gmail's is: "5" must not squat
|
||||||
|
// on as much white as "99+" does.
|
||||||
|
//
|
||||||
|
// What keeps a three-glyph label legible is not the width — it is the small
|
||||||
|
// corner radius, plus budgeting the font against the FULL span rather than
|
||||||
|
// against the fitted box. The rounded-end pill that preceded this failed for the
|
||||||
|
// first reason: round ends (rx = h/2) squander their horizontal extent on the
|
||||||
|
// curve, which is exactly the space three glyphs need, so at 16px "99+" was an
|
||||||
|
// illegible smudge — and at one digit the same pill read as a plain circle. Do
|
||||||
|
// not reinstate rx = h/2. Because the font is budgeted against the full span,
|
||||||
|
// "99+" shrinks to the size that would fit edge to edge, and its box then grows
|
||||||
|
// to fill the icon width anyway; "9" and "47" render at the cap in a box that
|
||||||
|
// hugs them.
|
||||||
|
const BAND_HEIGHT = 0.625; // band height, as a fraction of the icon span
|
||||||
|
const FONT_MAX = 0.61; // font-size cap, as a fraction of the icon span
|
||||||
|
const PAD_FACTOR = 0.04; // horizontal padding, as a fraction of the icon span, each side
|
||||||
|
const CORNER_FACTOR = 0.1; // corner radius, as a fraction of band height
|
||||||
|
const GLYPH_ADV = 0.6; // advance width per glyph, in em, for the sans badge font
|
||||||
|
|
||||||
|
// Counts above this render as "99+". Gmail caps at 20, and matching it was
|
||||||
|
// tried and reverted: the cap decides how often the label needs three glyphs,
|
||||||
|
// and three glyphs do not fit at the full font size. Capping at 20 meant a
|
||||||
|
// typical inbox showed "20+" at 84% of the cap size essentially always, where
|
||||||
|
// capping at 99 shows a real two-digit count at full size. Bigger digits and a
|
||||||
|
// number you can act on beat parity with Gmail's ceiling.
|
||||||
|
const BADGE_MAX = 99;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats an unread count for display in the badge.
|
||||||
|
* Returns an empty string when there is nothing to show.
|
||||||
|
*/
|
||||||
|
export function formatBadgeCount(count: number): string {
|
||||||
|
// `< 1`, not `<= 0`: a fractional count such as 0.5 would otherwise floor to
|
||||||
|
// 0 and draw a "0" badge, since String(0) is truthy.
|
||||||
|
if (!Number.isFinite(count) || count < 1) return '';
|
||||||
|
const whole = Math.floor(count);
|
||||||
|
return whole > BADGE_MAX ? `${BADGE_MAX}+` : String(whole);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strips anything active from the base SVG.
|
||||||
|
*
|
||||||
|
* The base may be an admin-uploaded file, which the branding route deliberately
|
||||||
|
* serves under a sandboxing CSP because SVG can carry script (see
|
||||||
|
* app/api/admin/branding/[filename]/route.ts). Re-emitting it verbatim as a
|
||||||
|
* same-origin `data:` URL inside our own document would un-fence exactly what
|
||||||
|
* that CSP fences, so remove script, foreignObject and every on* handler first.
|
||||||
|
*/
|
||||||
|
function sanitiseSvg(doc: Document): void {
|
||||||
|
doc.querySelectorAll('script, foreignObject').forEach((el) => el.remove());
|
||||||
|
|
||||||
|
doc.querySelectorAll('*').forEach((el) => {
|
||||||
|
for (const attr of Array.from(el.attributes)) {
|
||||||
|
if (attr.name.toLowerCase().startsWith('on')) {
|
||||||
|
el.removeAttributeNS(attr.namespaceURI, attr.localName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composes an unread badge over an SVG favicon and returns it as a data URL.
|
||||||
|
*
|
||||||
|
* Returns null — meaning "leave the favicon alone" — when the count is zero,
|
||||||
|
* or when the source is not usable SVG. Never throws.
|
||||||
|
*/
|
||||||
|
export function renderBadgedFavicon(baseSvgSource: string, count: number): string | null {
|
||||||
|
const label = formatBadgeCount(count);
|
||||||
|
if (!label) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const doc = new DOMParser().parseFromString(baseSvgSource, 'image/svg+xml');
|
||||||
|
|
||||||
|
if (doc.querySelector('parsererror')) return null;
|
||||||
|
|
||||||
|
const root = doc.documentElement;
|
||||||
|
// The namespace, not just the tag name: an <svg> with no xmlns parses fine
|
||||||
|
// but renders as nothing, so it would yield a non-null, blank data URL.
|
||||||
|
if (!root || root.localName !== 'svg' || root.namespaceURI !== SVG_NS) return null;
|
||||||
|
|
||||||
|
const viewBox = root.getAttribute('viewBox');
|
||||||
|
if (!viewBox) return null;
|
||||||
|
|
||||||
|
const [rawMinX, rawMinY, rawWidth, rawHeight] = viewBox.trim().split(/[\s,]+/).map(Number);
|
||||||
|
if (
|
||||||
|
![rawMinX, rawMinY, rawWidth, rawHeight].every(Number.isFinite) ||
|
||||||
|
rawWidth <= 0 ||
|
||||||
|
rawHeight <= 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitiseSvg(doc);
|
||||||
|
|
||||||
|
// The base declares "1000pt"; point units in a favicon are unreliable.
|
||||||
|
// Unitless 16 with the viewBox retained lets the browser rasterise cleanly
|
||||||
|
// at any size it asks for.
|
||||||
|
root.setAttribute('width', '16');
|
||||||
|
root.setAttribute('height', '16');
|
||||||
|
|
||||||
|
// Normalise the viewBox to a square, centred on the original, before doing
|
||||||
|
// any badge maths. Sizing the badge off min(width, height) double-penalised
|
||||||
|
// a non-square base: a 100x20 wordmark produced a ~2px-tall smudge on a
|
||||||
|
// 16px icon. Squaring first sizes the badge against the box the icon is
|
||||||
|
// actually painted into. It is a no-op for a square viewBox (Bulwark's own
|
||||||
|
// is 0 0 1000 1000). Caveat: a base that pairs a non-square viewBox with
|
||||||
|
// preserveAspectRatio="none" will now letterbox rather than stretch — an
|
||||||
|
// acceptable, arguably better, trade for a favicon, which is always square.
|
||||||
|
const side = Math.max(rawWidth, rawHeight);
|
||||||
|
const minX = rawMinX - (side - rawWidth) / 2;
|
||||||
|
const minY = rawMinY - (side - rawHeight) / 2;
|
||||||
|
root.setAttribute('viewBox', `${minX} ${minY} ${side} ${side}`);
|
||||||
|
|
||||||
|
const span = side;
|
||||||
|
const h = BAND_HEIGHT * span;
|
||||||
|
const fontMax = FONT_MAX * span;
|
||||||
|
const pad = PAD_FACTOR * span;
|
||||||
|
|
||||||
|
// The font first, budgeted against the FULL span: the largest size that
|
||||||
|
// would still leave the padding intact if the box ran edge to edge. That is
|
||||||
|
// the cap for one or two glyphs and a modest shrink for "99+".
|
||||||
|
const font = Math.min(fontMax, (span - 2 * pad) / (label.length * GLYPH_ADV));
|
||||||
|
// The box then hugs the label — never wider than the icon, anchored to the
|
||||||
|
// bottom-right corner. A three-glyph label, whose font was budgeted against
|
||||||
|
// the whole span, fills that span exactly; shorter labels get a narrower
|
||||||
|
// box, leaving the left of the base mark uncovered so the artwork stays
|
||||||
|
// recognisable. Gmail's own badge does the same: measured off its 16px
|
||||||
|
// favicon, a single digit sits hard right in a box about a third of the
|
||||||
|
// icon wide. Centring was tried and rejected — at one digit the box lands
|
||||||
|
// under the middle of the mark and bites a hole out of it.
|
||||||
|
const textW = label.length * GLYPH_ADV * font;
|
||||||
|
const w = Math.min(span, textW + 2 * pad);
|
||||||
|
const x = minX + span - w;
|
||||||
|
const y = minY + span - h;
|
||||||
|
const rx = CORNER_FACTOR * h;
|
||||||
|
|
||||||
|
const bandRect = doc.createElementNS(SVG_NS, 'rect');
|
||||||
|
bandRect.setAttribute('x', String(x));
|
||||||
|
bandRect.setAttribute('y', String(y));
|
||||||
|
bandRect.setAttribute('width', String(w));
|
||||||
|
bandRect.setAttribute('height', String(h));
|
||||||
|
bandRect.setAttribute('rx', String(rx));
|
||||||
|
bandRect.setAttribute('ry', String(rx));
|
||||||
|
// Presentation attributes lose to any CSS rule in the same document, and a
|
||||||
|
// branded base is free to carry `<style>rect{fill:#db2d54}</style>` — which
|
||||||
|
// would paint the badge red-on-red, the exact failure the white band exists
|
||||||
|
// to prevent. A style attribute outranks a stylesheet rule, so set both: the
|
||||||
|
// attribute as the guarantee, the presentation attribute as the fallback.
|
||||||
|
bandRect.setAttribute('fill', BADGE_FILL);
|
||||||
|
bandRect.setAttribute('style', `fill:${BADGE_FILL}`);
|
||||||
|
|
||||||
|
const text = doc.createElementNS(SVG_NS, 'text');
|
||||||
|
text.setAttribute('x', String(x + w / 2));
|
||||||
|
text.setAttribute('y', String(y + h / 2));
|
||||||
|
text.setAttribute('text-anchor', 'middle');
|
||||||
|
text.setAttribute('dominant-baseline', 'central');
|
||||||
|
text.setAttribute('font-family', BADGE_FONT);
|
||||||
|
// 500, not 700: at true 16px a bold count read visibly heavier than the
|
||||||
|
// equivalent badge in Gmail's tab, which is the thing users compare it to.
|
||||||
|
text.setAttribute('font-weight', '500');
|
||||||
|
text.setAttribute('font-size', String(font));
|
||||||
|
text.setAttribute('fill', BADGE_TEXT_FILL);
|
||||||
|
text.setAttribute(
|
||||||
|
'style',
|
||||||
|
`fill:${BADGE_TEXT_FILL};font-family:${BADGE_FONT};font-weight:500;font-size:${font}px`,
|
||||||
|
);
|
||||||
|
text.textContent = label;
|
||||||
|
|
||||||
|
root.appendChild(bandRect);
|
||||||
|
root.appendChild(text);
|
||||||
|
|
||||||
|
const serialised = new XMLSerializer().serializeToString(doc);
|
||||||
|
|
||||||
|
// Percent-encoding rather than base64: btoa throws on any character outside
|
||||||
|
// Latin-1, which a branded SVG may well contain. encodeURIComponent itself
|
||||||
|
// throws on an unpaired surrogate, so this whole tail is guarded. It must be
|
||||||
|
// encodeURIComponent, not encodeURI: the latter leaves "#" bare, and a bare
|
||||||
|
// "#" in a colour truncates the data URL at the first fill.
|
||||||
|
return `data:image/svg+xml,${encodeURIComponent(serialised)}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Zobrazit celkový počet zpráv",
|
"label": "Zobrazit celkový počet zpráv",
|
||||||
"description": "Zobrazí celkový počet zpráv vedle složek a štítků spolu s počtem nepřečtených. Vypněte pro zobrazení pouze nepřečtených."
|
"description": "Zobrazí celkový počet zpráv vedle složek a štítků spolu s počtem nepřečtených. Vypněte pro zobrazení pouze nepřečtených."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro rozhraní (experimentální)",
|
"label": "Pro rozhraní (experimentální)",
|
||||||
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
|
"description": "Rozložení pro pokročilé uživatele pouze pro stolní počítače s prohlížením zpráv na více kartách a pracovními postupy napříč účty. Standardní rozhraní zůstává nedotčeno; kdykoli se můžete vrátit.",
|
||||||
|
|||||||
@@ -952,6 +952,10 @@
|
|||||||
"label": "Vis samlet antal beskeder",
|
"label": "Vis samlet antal beskeder",
|
||||||
"description": "Viser det samlede antal beskeder ud for mapper og tags sammen med antallet af ulæste. Slå fra for kun at vise ulæste."
|
"description": "Viser det samlede antal beskeder ud for mapper og tags sammen med antallet af ulæste. Slå fra for kun at vise ulæste."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro-grænseflade (eksperimentel)",
|
"label": "Pro-grænseflade (eksperimentel)",
|
||||||
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
|
"description": "Power user-layout kun til skrivebordet med beskedvisning på flere faner og arbejdsforløb på tværs af konti. Standardgrænsefladen påvirkes ikke; du kan skifte tilbage når som helst.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Gesamtzahl der Nachrichten anzeigen",
|
"label": "Gesamtzahl der Nachrichten anzeigen",
|
||||||
"description": "Zeigt neben Ordnern und Tags die Gesamtzahl der Nachrichten zusätzlich zur Anzahl ungelesener Nachrichten an. Deaktivieren, um nur ungelesene Nachrichten anzuzeigen."
|
"description": "Zeigt neben Ordnern und Tags die Gesamtzahl der Nachrichten zusätzlich zur Anzahl ungelesener Nachrichten an. Deaktivieren, um nur ungelesene Nachrichten anzuzeigen."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro-Oberfläche (experimentell)",
|
"label": "Pro-Oberfläche (experimentell)",
|
||||||
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
|
"description": "Desktop-Power-User-Layout mit Multi-Tab-Nachrichtenansicht und kontoübergreifenden Workflows. Die Standardoberfläche bleibt unverändert; Sie können jederzeit zurückwechseln.",
|
||||||
|
|||||||
@@ -952,6 +952,10 @@
|
|||||||
"label": "Show Total Message Count",
|
"label": "Show Total Message Count",
|
||||||
"description": "Show the total message count next to folders and tags, alongside the unread count. Disable to show only unread counts."
|
"description": "Show the total message count next to folders and tags, alongside the unread count. Disable to show only unread counts."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro Interface (Experimental)",
|
"label": "Pro Interface (Experimental)",
|
||||||
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
|
"description": "Desktop-only power-user layout with multi-tab message browsing and cross-account workflows. The standard interface is unaffected; you can switch back at any time.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Mostrar el número total de mensajes",
|
"label": "Mostrar el número total de mensajes",
|
||||||
"description": "Muestra el número total de mensajes junto a las carpetas y etiquetas, además del número de mensajes no leídos. Desactívalo para mostrar solo los no leídos."
|
"description": "Muestra el número total de mensajes junto a las carpetas y etiquetas, además del número de mensajes no leídos. Desactívalo para mostrar solo los no leídos."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Interfaz Pro (experimental)",
|
"label": "Interfaz Pro (experimental)",
|
||||||
"description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.",
|
"description": "Diseño de escritorio para usuarios avanzados con exploración de mensajes en varias pestañas y flujos de trabajo entre cuentas. La interfaz estándar no se ve afectada; puedes volver en cualquier momento.",
|
||||||
|
|||||||
@@ -952,6 +952,10 @@
|
|||||||
"label": "نمایش تعداد کل پیامها",
|
"label": "نمایش تعداد کل پیامها",
|
||||||
"description": "تعداد کل پیامها را در کنار پوشهها و برچسبها، همراه با تعداد خواندهنشدهها نمایش میدهد. برای نمایش فقط تعداد خواندهنشدهها غیرفعال کنید."
|
"description": "تعداد کل پیامها را در کنار پوشهها و برچسبها، همراه با تعداد خواندهنشدهها نمایش میدهد. برای نمایش فقط تعداد خواندهنشدهها غیرفعال کنید."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "رابط حرفهای (آزمایشی)",
|
"label": "رابط حرفهای (آزمایشی)",
|
||||||
"description": "چیدمان قدرت-کاربری فقط دسکتاپ",
|
"description": "چیدمان قدرت-کاربری فقط دسکتاپ",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Afficher le nombre total de messages",
|
"label": "Afficher le nombre total de messages",
|
||||||
"description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus."
|
"description": "Affiche le nombre total de messages à côté des dossiers et des étiquettes, en plus du nombre de messages non lus. Désactivez pour n'afficher que les messages non lus."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Interface Pro (expérimental)",
|
"label": "Interface Pro (expérimental)",
|
||||||
"description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.",
|
"description": "Disposition pour utilisateurs avancés (bureau uniquement) avec navigation des messages multi-onglets et flux de travail multi-comptes. L'interface standard n'est pas affectée ; vous pouvez revenir à tout moment.",
|
||||||
|
|||||||
@@ -914,6 +914,10 @@
|
|||||||
"label": "הצגת מספר ההודעות הכולל",
|
"label": "הצגת מספר ההודעות הכולל",
|
||||||
"description": "מציג את מספר ההודעות הכולל לצד תיקיות ותגיות, לצד מספר ההודעות שלא נקראו. כבו כדי להציג רק את מספר ההודעות שלא נקראו."
|
"description": "מציג את מספר ההודעות הכולל לצד תיקיות ותגיות, לצד מספר ההודעות שלא נקראו. כבו כדי להציג רק את מספר ההודעות שלא נקראו."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "ממשק Pro (ניסיוני)",
|
"label": "ממשק Pro (ניסיוני)",
|
||||||
"description": "פריסה של משתמש כוח לשולחן עבודה בלבד עם גלישה מרובת-הודעות וזרימות עבודה חוצות-חשבונות. הממשק הסטנדרטי אינו מושפע; תוכל להחזור בכל עת.",
|
"description": "פריסה של משתמש כוח לשולחן עבודה בלבד עם גלישה מרובת-הודעות וזרימות עבודה חוצות-חשבונות. הממשק הסטנדרטי אינו מושפע; תוכל להחזור בכל עת.",
|
||||||
|
|||||||
@@ -952,6 +952,10 @@
|
|||||||
"label": "Összes üzenet számának megjelenítése",
|
"label": "Összes üzenet számának megjelenítése",
|
||||||
"description": "Megjeleníti az üzenetek teljes számát a mappák és címkék mellett, az olvasatlanok számán túl. Kapcsolja ki, ha csak az olvasatlanok számát szeretné látni."
|
"description": "Megjeleníti az üzenetek teljes számát a mappák és címkék mellett, az olvasatlanok számán túl. Kapcsolja ki, ha csak az olvasatlanok számát szeretné látni."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro felület (Kísérleti)",
|
"label": "Pro felület (Kísérleti)",
|
||||||
"description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.",
|
"description": "Asztali számítógépes erőfelhasználói elrendezés több lapos üzenetböngészéssel és fiókok közötti munkafolyamatokkal. A szabványos felületet nem érinti; bármikor visszaválthatsz.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Mostra il numero totale di messaggi",
|
"label": "Mostra il numero totale di messaggi",
|
||||||
"description": "Mostra il numero totale di messaggi accanto a cartelle ed etichette, insieme al conteggio dei non letti. Disattiva per mostrare solo i non letti."
|
"description": "Mostra il numero totale di messaggi accanto a cartelle ed etichette, insieme al conteggio dei non letti. Disattiva per mostrare solo i non letti."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Interfaccia Pro (sperimentale)",
|
"label": "Interfaccia Pro (sperimentale)",
|
||||||
"description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.",
|
"description": "Layout per utenti esperti solo desktop con esplorazione messaggi a più schede e flussi tra account. L'interfaccia standard non è influenzata; puoi tornare indietro in qualsiasi momento.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "メッセージの総数を表示",
|
"label": "メッセージの総数を表示",
|
||||||
"description": "フォルダーやタグの横に、未読数に加えてメッセージの総数を表示します。未読数のみを表示するには無効にします。"
|
"description": "フォルダーやタグの横に、未読数に加えてメッセージの総数を表示します。未読数のみを表示するには無効にします。"
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro インターフェイス(実験的)",
|
"label": "Pro インターフェイス(実験的)",
|
||||||
"description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
|
"description": "デスクトップ専用のパワーユーザー向けレイアウトで、マルチタブのメッセージ閲覧やアカウント横断のワークフローに対応します。標準インターフェイスには影響せず、いつでも元に戻せます。",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "전체 메시지 수 표시",
|
"label": "전체 메시지 수 표시",
|
||||||
"description": "읽지 않은 수와 함께 폴더 및 태그 옆에 전체 메시지 수를 표시합니다. 읽지 않은 수만 표시하려면 비활성화하세요."
|
"description": "읽지 않은 수와 함께 폴더 및 태그 옆에 전체 메시지 수를 표시합니다. 읽지 않은 수만 표시하려면 비활성화하세요."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro 인터페이스 (실험적)",
|
"label": "Pro 인터페이스 (실험적)",
|
||||||
"description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
|
"description": "데스크톱 전용 파워 유저 레이아웃으로, 다중 탭 메시지 탐색과 계정 간 워크플로우를 지원합니다. 표준 인터페이스에는 영향이 없으며 언제든지 되돌릴 수 있습니다.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Rādīt kopējo ziņojumu skaitu",
|
"label": "Rādīt kopējo ziņojumu skaitu",
|
||||||
"description": "Rāda kopējo ziņojumu skaitu blakus mapēm un tagiem, kā arī nelasīto ziņojumu skaitu. Atspējojiet, lai rādītu tikai nelasītos."
|
"description": "Rāda kopējo ziņojumu skaitu blakus mapēm un tagiem, kā arī nelasīto ziņojumu skaitu. Atspējojiet, lai rādītu tikai nelasītos."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro saskarne (eksperimentāla)",
|
"label": "Pro saskarne (eksperimentāla)",
|
||||||
"description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.",
|
"description": "Tikai darbvirsmas pieredzējušu lietotāju izkārtojums ar ziņojumu pārlūkošanu vairākās cilnēs un kontu pārvaldību. Standarta saskarne netiek ietekmēta; varat jebkurā brīdī pārslēgties atpakaļ.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Totaal aantal berichten tonen",
|
"label": "Totaal aantal berichten tonen",
|
||||||
"description": "Toont het totale aantal berichten naast mappen en labels, naast het aantal ongelezen berichten. Schakel uit om alleen ongelezen aantallen te tonen."
|
"description": "Toont het totale aantal berichten naast mappen en labels, naast het aantal ongelezen berichten. Schakel uit om alleen ongelezen aantallen te tonen."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro-interface (experimenteel)",
|
"label": "Pro-interface (experimenteel)",
|
||||||
"description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.",
|
"description": "Power user-indeling alleen voor desktop met meertabs berichtweergave en accountoverschrijdende workflows. De standaardinterface blijft onveranderd; u kunt op elk moment terugkeren.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Pokaż łączną liczbę wiadomości",
|
"label": "Pokaż łączną liczbę wiadomości",
|
||||||
"description": "Pokazuje łączną liczbę wiadomości obok folderów i etykiet, obok liczby nieprzeczytanych. Wyłącz, aby pokazywać tylko nieprzeczytane."
|
"description": "Pokazuje łączną liczbę wiadomości obok folderów i etykiet, obok liczby nieprzeczytanych. Wyłącz, aby pokazywać tylko nieprzeczytane."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Interfejs Pro (eksperymentalny)",
|
"label": "Interfejs Pro (eksperymentalny)",
|
||||||
"description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.",
|
"description": "Układ dla zaawansowanych użytkowników (tylko na komputerze) z przeglądaniem wiadomości w wielu kartach i obiegami pracy między kontami. Standardowy interfejs pozostaje nietknięty; możesz wrócić w dowolnej chwili.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Mostrar contagem total de mensagens",
|
"label": "Mostrar contagem total de mensagens",
|
||||||
"description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas."
|
"description": "Mostra a contagem total de mensagens ao lado de pastas e etiquetas, além da contagem de não lidas. Desative para mostrar apenas as não lidas."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Interface Pro (experimental)",
|
"label": "Interface Pro (experimental)",
|
||||||
"description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.",
|
"description": "Layout para utilizadores avançados apenas em desktop com navegação de mensagens em múltiplos separadores e fluxos entre contas. A interface padrão não é afetada; pode voltar a qualquer momento.",
|
||||||
|
|||||||
@@ -952,6 +952,10 @@
|
|||||||
"label": "Afișează numărul total de mesaje",
|
"label": "Afișează numărul total de mesaje",
|
||||||
"description": "Afișează numărul total de mesaje lângă foldere și etichete, pe lângă numărul celor necitite. Dezactivează pentru a afișa doar mesajele necitite."
|
"description": "Afișează numărul total de mesaje lângă foldere și etichete, pe lângă numărul celor necitite. Dezactivează pentru a afișa doar mesajele necitite."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Interfață Pro (experimentală)",
|
"label": "Interfață Pro (experimentală)",
|
||||||
"description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.",
|
"description": "Aspect destinat utilizatorilor avansați, disponibil doar pe desktop, cu navigare prin mesaje în mai multe file și fluxuri de lucru între conturi. Interfața standard nu este afectată; puteți reveni la aceasta în orice moment.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Показывать общее количество сообщений",
|
"label": "Показывать общее количество сообщений",
|
||||||
"description": "Показывает общее количество сообщений рядом с папками и метками, наряду с количеством непрочитанных. Отключите, чтобы показывать только непрочитанные."
|
"description": "Показывает общее количество сообщений рядом с папками и метками, наряду с количеством непрочитанных. Отключите, чтобы показывать только непрочитанные."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro-интерфейс (экспериментальный)",
|
"label": "Pro-интерфейс (экспериментальный)",
|
||||||
"description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
|
"description": "Макет для опытных пользователей только для настольных устройств с просмотром сообщений в нескольких вкладках и работой между аккаунтами. Стандартный интерфейс не меняется; вы можете вернуться в любое время.",
|
||||||
|
|||||||
@@ -952,6 +952,10 @@
|
|||||||
"label": "Zobraziť celkový počet správ",
|
"label": "Zobraziť celkový počet správ",
|
||||||
"description": "Zobraziť celkový počet správ vedľa priečinkov a štítkov spolu s počtom neprečítaných."
|
"description": "Zobraziť celkový počet správ vedľa priečinkov a štítkov spolu s počtom neprečítaných."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro rozhranie (experimentálne)",
|
"label": "Pro rozhranie (experimentálne)",
|
||||||
"description": "Rozloženie pre pokročilých používateľov iba pre stolné počítače.",
|
"description": "Rozloženie pre pokročilých používateľov iba pre stolné počítače.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Toplam mesaj sayısını göster",
|
"label": "Toplam mesaj sayısını göster",
|
||||||
"description": "Klasörlerin ve etiketlerin yanında, okunmamış sayısının yanı sıra toplam mesaj sayısını gösterir. Yalnızca okunmamışları göstermek için devre dışı bırakın."
|
"description": "Klasörlerin ve etiketlerin yanında, okunmamış sayısının yanı sıra toplam mesaj sayısını gösterir. Yalnızca okunmamışları göstermek için devre dışı bırakın."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro Arayüz (Deneysel)",
|
"label": "Pro Arayüz (Deneysel)",
|
||||||
"description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.",
|
"description": "Yalnızca masaüstü için güçlü kullanıcı düzeni: çoklu sekmede ileti gezme ve hesaplar arası iş akışları. Standart arayüz etkilenmez; istediğiniz zaman geri dönebilirsiniz.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "Показувати загальну кількість повідомлень",
|
"label": "Показувати загальну кількість повідомлень",
|
||||||
"description": "Показує загальну кількість повідомлень поруч із теками та мітками, разом із кількістю непрочитаних. Вимкніть, щоб показувати лише непрочитані."
|
"description": "Показує загальну кількість повідомлень поруч із теками та мітками, разом із кількістю непрочитаних. Вимкніть, щоб показувати лише непрочитані."
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro-інтерфейс (експериментальний)",
|
"label": "Pro-інтерфейс (експериментальний)",
|
||||||
"description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
|
"description": "Розкладка для досвідчених користувачів лише для настільного комп'ютера з переглядом повідомлень у кількох вкладках і робочими процесами між обліковими записами. Стандартний інтерфейс не змінюється; ви можете повернутися будь-коли.",
|
||||||
|
|||||||
@@ -949,6 +949,10 @@
|
|||||||
"label": "显示邮件总数",
|
"label": "显示邮件总数",
|
||||||
"description": "在文件夹和标签旁边显示邮件总数,以及未读数量。停用后仅显示未读数量。"
|
"description": "在文件夹和标签旁边显示邮件总数,以及未读数量。停用后仅显示未读数量。"
|
||||||
},
|
},
|
||||||
|
"favicon_unread_badge": {
|
||||||
|
"label": "Unread Count on Tab Icon",
|
||||||
|
"description": "Show the inbox unread count as a badge on the browser tab icon, so new mail is visible when the tab is not focused. Disable to keep the icon unbadged."
|
||||||
|
},
|
||||||
"pro_interface": {
|
"pro_interface": {
|
||||||
"label": "Pro 界面(实验性)",
|
"label": "Pro 界面(实验性)",
|
||||||
"description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",
|
"description": "仅限桌面的高级用户布局,支持多标签消息浏览和跨账户工作流。标准界面不受影响,您可以随时切换回来。",
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from 'vitest';
|
||||||
|
import { useSettingsStore } from '../settings-store';
|
||||||
|
|
||||||
|
describe('settings-store favicon unread badge', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useSettingsStore.getState().resetToDefaults();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to on', () => {
|
||||||
|
expect(useSettingsStore.getState().faviconUnreadBadge).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes the favicon unread badge in exported settings', () => {
|
||||||
|
useSettingsStore.getState().updateSetting('faviconUnreadBadge', false);
|
||||||
|
|
||||||
|
const exported = JSON.parse(useSettingsStore.getState().exportSettings()) as {
|
||||||
|
faviconUnreadBadge?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(exported.faviconUnreadBadge).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -262,6 +262,7 @@ interface SettingsState {
|
|||||||
|
|
||||||
senderFavicons: boolean;
|
senderFavicons: boolean;
|
||||||
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
|
showAvatarsInJunk: boolean; // Show profile images/favicons in the junk folder
|
||||||
|
faviconUnreadBadge: boolean; // Badge the browser-tab icon with the inbox unread count
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
|
colorfulSidebarIcons: boolean; // Tint folder icons by role (inbox blue, junk red, etc.)
|
||||||
@@ -459,6 +460,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
|
|
||||||
senderFavicons: true,
|
senderFavicons: true,
|
||||||
showAvatarsInJunk: false,
|
showAvatarsInJunk: false,
|
||||||
|
faviconUnreadBadge: true,
|
||||||
|
|
||||||
// Sidebar
|
// Sidebar
|
||||||
colorfulSidebarIcons: true,
|
colorfulSidebarIcons: true,
|
||||||
@@ -639,6 +641,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
enableCrossAllView: state.enableCrossAllView,
|
enableCrossAllView: state.enableCrossAllView,
|
||||||
senderFavicons: state.senderFavicons,
|
senderFavicons: state.senderFavicons,
|
||||||
showAvatarsInJunk: state.showAvatarsInJunk,
|
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||||
|
faviconUnreadBadge: state.faviconUnreadBadge,
|
||||||
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
||||||
tintListRowsByTag: state.tintListRowsByTag,
|
tintListRowsByTag: state.tintListRowsByTag,
|
||||||
showFolderTotalCount: state.showFolderTotalCount,
|
showFolderTotalCount: state.showFolderTotalCount,
|
||||||
|
|||||||
Reference in New Issue
Block a user