Merge upstream/main to resolve conflicts
Both sides added adjacent LOGIN_* config entries (upstream: loginShowHeading/loginShowSubtitle/logo sizing; this branch: loginShowTotp/loginShowVersion) — resolution keeps both. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LrR2CVfvcPWxr9ub299VwW
This commit is contained in:
@@ -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,251 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useLocale } from "next-intl";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import {
|
||||
toJalali,
|
||||
jalaliMonthLength,
|
||||
startOfJalaliMonth,
|
||||
endOfJalaliMonth,
|
||||
eachDayOfJalaliMonth,
|
||||
getDayHeaderKeys,
|
||||
shouldUseJalaliCalendar,
|
||||
JALALI_MONTHS,
|
||||
type JalaliDate,
|
||||
} from "@/lib/jalali-utils";
|
||||
import {
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfWeek,
|
||||
endOfWeek,
|
||||
eachDayOfInterval,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isToday,
|
||||
} from "date-fns";
|
||||
|
||||
/**
|
||||
* Unified calendar-locale hook.
|
||||
*
|
||||
* Abstracts away the differences between Gregorian and Jalali calendars so
|
||||
* view components can render dates correctly without calendar-specific
|
||||
* branching.
|
||||
*/
|
||||
export function useCalendarLocale() {
|
||||
const locale = useLocale();
|
||||
const firstDayOfWeek = useSettingsStore((s) => s.firstDayOfWeek);
|
||||
const isJalali = shouldUseJalaliCalendar(locale);
|
||||
|
||||
// Normalize weekStart for date-fns (0 | 1 | 2 | 3 | 4 | 5 | 6)
|
||||
const weekStartsOn = useMemo(() => {
|
||||
if (firstDayOfWeek === 0) return 0 as const;
|
||||
if (firstDayOfWeek === 6) return 6 as const;
|
||||
return 1 as const;
|
||||
}, [firstDayOfWeek]);
|
||||
|
||||
// Ordered day-header translation keys
|
||||
const dayHeaderKeys = useMemo(
|
||||
() => getDayHeaderKeys(weekStartsOn),
|
||||
[weekStartsOn],
|
||||
);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Month-grid construction
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Build the flat array of Dates that populate a full month grid. */
|
||||
const getMonthGridDays = (referenceDate: Date): Date[] => {
|
||||
if (isJalali) {
|
||||
const { jy, jm } = toJalali(referenceDate);
|
||||
return eachDayOfJalaliMonth(jy, jm, weekStartsOn);
|
||||
}
|
||||
const monthStart = startOfMonth(referenceDate);
|
||||
const monthEnd = endOfMonth(referenceDate);
|
||||
const gridStart = startOfWeek(monthStart, { weekStartsOn });
|
||||
const gridEnd = endOfWeek(monthEnd, { weekStartsOn });
|
||||
return eachDayOfInterval({ start: gridStart, end: gridEnd });
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Day-level queries
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Is the given date "today" in the active calendar system? */
|
||||
const checkIsToday = (date: Date): boolean => {
|
||||
if (isJalali) {
|
||||
const now = toJalali(new Date());
|
||||
const target = toJalali(date);
|
||||
return now.jy === target.jy && now.jm === target.jm && now.jd === target.jd;
|
||||
}
|
||||
return isToday(date);
|
||||
};
|
||||
|
||||
/** Does the date belong to the same month as the reference date? */
|
||||
const checkIsSameMonth = (date: Date, referenceDate: Date): boolean => {
|
||||
if (isJalali) {
|
||||
const a = toJalali(date);
|
||||
const b = toJalali(referenceDate);
|
||||
return a.jy === b.jy && a.jm === b.jm;
|
||||
}
|
||||
return isSameMonth(date, referenceDate);
|
||||
};
|
||||
|
||||
/** Are two dates the same calendar day? */
|
||||
const checkIsSameDay = (date1: Date, date2: Date): boolean => {
|
||||
if (isJalali) {
|
||||
const a = toJalali(date1);
|
||||
const b = toJalali(date2);
|
||||
return a.jy === b.jy && a.jm === b.jm && a.jd === b.jd;
|
||||
}
|
||||
return isSameDay(date1, date2);
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Display formatting
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/** Day-of-month number for a calendar cell (string). */
|
||||
const formatDayNumber = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
return String(toJalali(date).jd);
|
||||
}
|
||||
return String(date.getDate());
|
||||
};
|
||||
|
||||
/** Full month + year label for the toolbar / mini-calendar header. */
|
||||
const formatMonthYear = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm } = toJalali(date);
|
||||
return `${JALALI_MONTHS[jm - 1]} ${jy}`;
|
||||
}
|
||||
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "long",
|
||||
});
|
||||
return `${month} ${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** Short month + year for mobile. */
|
||||
const formatMonthYearShort = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm } = toJalali(date);
|
||||
const short = JALALI_MONTHS[jm - 1].slice(0, 3);
|
||||
return `${short} ${jy}`;
|
||||
}
|
||||
const month = date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
});
|
||||
return `${month} ${date.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** Week range label (e.g. "6 – 12 Farvardin 1404"). */
|
||||
const formatWeekRange = (weekStart: Date): string => {
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
if (isJalali) {
|
||||
const start = toJalali(weekStart);
|
||||
const end = toJalali(weekEnd);
|
||||
if (start.jm === end.jm) {
|
||||
return `${start.jd} – ${end.jd} ${JALALI_MONTHS[start.jm - 1]} ${start.jy}`;
|
||||
}
|
||||
return `${start.jd} ${JALALI_MONTHS[start.jm - 1]} – ${end.jd} ${JALALI_MONTHS[end.jm - 1]} ${end.jy}`;
|
||||
}
|
||||
const sameMonth = weekStart.getMonth() === weekEnd.getMonth();
|
||||
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: sameMonth ? undefined : "short",
|
||||
day: "numeric",
|
||||
});
|
||||
return `${s} – ${e}, ${weekEnd.getFullYear()}`;
|
||||
};
|
||||
|
||||
/** Short week range for mobile. */
|
||||
const formatWeekRangeShort = (weekStart: Date): string => {
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||||
if (isJalali) {
|
||||
const start = toJalali(weekStart);
|
||||
const end = toJalali(weekEnd);
|
||||
return `${start.jd}/${start.jm} – ${end.jd}/${end.jm}`;
|
||||
}
|
||||
const s = weekStart.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const e = weekEnd.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
day: "numeric",
|
||||
});
|
||||
return `${s} – ${e}`;
|
||||
};
|
||||
|
||||
/** Full date label for accessibility / tooltips. */
|
||||
const formatFullDate = (date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm, jd } = toJalali(date);
|
||||
const dayOfWeek = date.getDay();
|
||||
const dayNames = getDayHeaderKeys(weekStartsOn);
|
||||
// Map from Gregorian day index to the correct label from the reordered list
|
||||
const dayIdx = (dayOfWeek - weekStartsOn + 7) % 7;
|
||||
const dayKey = dayNames[dayIdx];
|
||||
return `${dayKey} ${jd} ${JALALI_MONTHS[jm - 1]} ${jy}`;
|
||||
}
|
||||
return date.toLocaleString(locale === "en" ? "en-US" : locale, {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Calendar-system-aware month/year getters (for navigation, etc.)
|
||||
// All return values use **0-based** months to stay compatible with
|
||||
// date-fns functions like `setMonth`.
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
const getMonth = (date: Date): number => {
|
||||
if (isJalali) return toJalali(date).jm - 1; // 0-11
|
||||
return date.getMonth(); // 0-11
|
||||
};
|
||||
|
||||
const getYear = (date: Date): number => {
|
||||
if (isJalali) return toJalali(date).jy;
|
||||
return date.getFullYear();
|
||||
};
|
||||
|
||||
/** Keys for the month selector dropdown (used by MiniCalendar). */
|
||||
const monthLabelKeys = useMemo(() => {
|
||||
if (isJalali) {
|
||||
return [
|
||||
"far", "ord", "kho", "tir", "mor", "sha",
|
||||
"meh", "aba", "aza", "dey", "bah", "esf",
|
||||
];
|
||||
}
|
||||
return [
|
||||
"jan", "feb", "mar", "apr", "may", "jun",
|
||||
"jul", "aug", "sep", "oct", "nov", "dec",
|
||||
];
|
||||
}, [isJalali]);
|
||||
|
||||
return {
|
||||
isJalali,
|
||||
weekStartsOn,
|
||||
dayHeaderKeys,
|
||||
getMonthGridDays,
|
||||
checkIsToday,
|
||||
checkIsSameMonth,
|
||||
checkIsSameDay,
|
||||
formatDayNumber,
|
||||
formatMonthYear,
|
||||
formatMonthYearShort,
|
||||
formatWeekRange,
|
||||
formatWeekRangeShort,
|
||||
formatFullDate,
|
||||
getMonth,
|
||||
getYear,
|
||||
monthLabelKeys,
|
||||
} as const;
|
||||
}
|
||||
@@ -26,6 +26,10 @@ interface ConfigData {
|
||||
loginImprintUrl: string;
|
||||
loginPrivacyPolicyUrl: string;
|
||||
loginWebsiteUrl: string;
|
||||
loginLogoMaxHeight: string;
|
||||
loginLogoMaxWidth: string;
|
||||
loginShowHeading: boolean;
|
||||
loginShowSubtitle: boolean;
|
||||
loginShowTotp: boolean;
|
||||
loginShowVersion: boolean;
|
||||
demoMode: boolean;
|
||||
@@ -107,6 +111,10 @@ export function useConfig(): AppConfig {
|
||||
loginImprintUrl: configCache?.loginImprintUrl || '',
|
||||
loginPrivacyPolicyUrl: configCache?.loginPrivacyPolicyUrl || '',
|
||||
loginWebsiteUrl: configCache?.loginWebsiteUrl || '',
|
||||
loginLogoMaxHeight: configCache?.loginLogoMaxHeight || '',
|
||||
loginLogoMaxWidth: configCache?.loginLogoMaxWidth || '',
|
||||
loginShowHeading: configCache?.loginShowHeading ?? true,
|
||||
loginShowSubtitle: configCache?.loginShowSubtitle ?? true,
|
||||
loginShowTotp: configCache?.loginShowTotp ?? true,
|
||||
loginShowVersion: configCache?.loginShowVersion ?? true,
|
||||
demoMode: configCache?.demoMode || false,
|
||||
@@ -144,6 +152,10 @@ export function useConfig(): AppConfig {
|
||||
loginImprintUrl: configCache.loginImprintUrl,
|
||||
loginPrivacyPolicyUrl: configCache.loginPrivacyPolicyUrl,
|
||||
loginWebsiteUrl: configCache.loginWebsiteUrl,
|
||||
loginLogoMaxHeight: configCache.loginLogoMaxHeight,
|
||||
loginLogoMaxWidth: configCache.loginLogoMaxWidth,
|
||||
loginShowHeading: configCache.loginShowHeading,
|
||||
loginShowSubtitle: configCache.loginShowSubtitle,
|
||||
loginShowTotp: configCache.loginShowTotp,
|
||||
loginShowVersion: configCache.loginShowVersion,
|
||||
demoMode: configCache.demoMode,
|
||||
@@ -182,6 +194,10 @@ export function useConfig(): AppConfig {
|
||||
loginImprintUrl: data.loginImprintUrl,
|
||||
loginPrivacyPolicyUrl: data.loginPrivacyPolicyUrl,
|
||||
loginWebsiteUrl: data.loginWebsiteUrl,
|
||||
loginLogoMaxHeight: data.loginLogoMaxHeight,
|
||||
loginLogoMaxWidth: data.loginLogoMaxWidth,
|
||||
loginShowHeading: data.loginShowHeading,
|
||||
loginShowSubtitle: data.loginShowSubtitle,
|
||||
loginShowTotp: data.loginShowTotp,
|
||||
loginShowVersion: data.loginShowVersion,
|
||||
demoMode: data.demoMode,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -1,27 +1,42 @@
|
||||
import { useCallback } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useTranslations, useLocale } from "next-intl";
|
||||
import { format } from "date-fns";
|
||||
import { toJalali, shouldUseJalaliCalendar, JALALI_MONTHS } from "@/lib/jalali-utils";
|
||||
|
||||
/**
|
||||
* Returns a memoized function that formats a calendar event date
|
||||
* using the current locale for day and month names.
|
||||
*
|
||||
*
|
||||
* The string will be in the format: "EEE, MMM d, yyyy"
|
||||
*
|
||||
*
|
||||
* For example: "Wed, Apr 29, 2026" (en)
|
||||
* "Qua, Abr 29, 2026" (pt)
|
||||
*
|
||||
* When the Jalali calendar is active (fa locale), the format uses
|
||||
* Persian day/month names with the Jalali year, e.g.:
|
||||
* "چهارشنبه, ۹ اردیبهشت ۱۴۰۵"
|
||||
*/
|
||||
export function useFormatEventDate(): (date: Date) => string {
|
||||
const t = useTranslations("calendar");
|
||||
const locale = useLocale();
|
||||
const isJalali = shouldUseJalaliCalendar(locale);
|
||||
|
||||
return useCallback(
|
||||
(date: Date): string => {
|
||||
if (isJalali) {
|
||||
const { jy, jm, jd } = toJalali(date);
|
||||
// Use Gregorian day-of-week for the translation key (date-fns format)
|
||||
const dayOfWeek = format(date, "EEE").toLowerCase();
|
||||
const monthName = JALALI_MONTHS[jm - 1];
|
||||
return `${t(`days.${dayOfWeek}`)}, ${jd} ${monthName} ${jy}`;
|
||||
}
|
||||
|
||||
const dayOfWeek = format(date, "EEE").toLowerCase();
|
||||
const month = format(date, "MMM").toLowerCase();
|
||||
const day = format(date, "d");
|
||||
const year = format(date, "yyyy");
|
||||
return `${t(`days.${dayOfWeek}`)}, ${t(`months.${month}`)} ${day}, ${year}`;
|
||||
},
|
||||
[t]
|
||||
[t, isJalali]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useCallback, useRef } from "react";
|
||||
import { Email } from "@/lib/jmap/types";
|
||||
import { isEditableEventTarget } from "@/lib/keyboard";
|
||||
|
||||
export interface KeyboardShortcutHandlers {
|
||||
// Navigation
|
||||
@@ -43,16 +44,27 @@ export interface UseKeyboardShortcutsOptions {
|
||||
handlers: KeyboardShortcutHandlers;
|
||||
}
|
||||
|
||||
// Check if user is typing in an input field
|
||||
function isInputFocused(): boolean {
|
||||
const activeElement = document.activeElement;
|
||||
if (!activeElement) return false;
|
||||
|
||||
const tagName = activeElement.tagName.toLowerCase();
|
||||
const isInput = tagName === "input" || tagName === "textarea" || tagName === "select";
|
||||
const isContentEditable = activeElement.getAttribute("contenteditable") === "true";
|
||||
|
||||
return isInput || isContentEditable;
|
||||
// Shortcuts must fire regardless of the active keyboard layout (e.g. Cyrillic,
|
||||
// Greek). Derive the key from the PHYSICAL key (event.code) instead of the
|
||||
// layout-dependent event.key: letters from KeyA..KeyZ, and the symbol shortcuts
|
||||
// (/, ?, #, !) from their US-QWERTY positions so they stay reachable on non-Latin
|
||||
// layouts. Arrows/Enter/Escape keep event.key, which is already layout-neutral.
|
||||
function physicalShortcutKey(event: KeyboardEvent): string {
|
||||
const code = event.code;
|
||||
if (code && code.length === 4 && code.startsWith("Key")) {
|
||||
return code.charAt(3).toLowerCase();
|
||||
}
|
||||
switch (code) {
|
||||
case "Slash":
|
||||
return event.shiftKey ? "?" : "/";
|
||||
case "Digit1":
|
||||
if (event.shiftKey) return "!";
|
||||
break;
|
||||
case "Digit3":
|
||||
if (event.shiftKey) return "#";
|
||||
break;
|
||||
}
|
||||
return event.key.toLowerCase();
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
@@ -71,11 +83,13 @@ export function useKeyboardShortcuts({
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (isInputFocused()) return;
|
||||
// Don't trigger shortcuts when typing in inputs. Must be event-based
|
||||
// (composedPath), not document.activeElement: the QuotedHtml island's
|
||||
// shadow root retargets activeElement to its plain-div host (#654).
|
||||
if (isEditableEventTarget(event)) return;
|
||||
|
||||
const h = handlersRef.current;
|
||||
const key = event.key.toLowerCase();
|
||||
const key = physicalShortcutKey(event);
|
||||
const hasModifier = event.ctrlKey || event.metaKey || event.altKey;
|
||||
|
||||
// Shortcuts that work with modifiers
|
||||
|
||||
+15
-3
@@ -74,14 +74,26 @@ export function useTagDrop({ tagId, onSuccess, onError }: UseTagDropOptions): Us
|
||||
|
||||
for (const emailId of emailIds) {
|
||||
// Read fresh state to avoid stale closures
|
||||
const currentEmails = useEmailStore.getState().emails;
|
||||
const email = currentEmails.find(em => em.id === emailId);
|
||||
const emailState = useEmailStore.getState();
|
||||
const email = emailState.emails.find(em => em.id === emailId);
|
||||
const keywords = { ...(email?.keywords || {}) };
|
||||
|
||||
// Add the tag without removing existing ones
|
||||
keywords[`$label:${tagId}`] = true;
|
||||
|
||||
await client.updateEmailKeywords(emailId, keywords);
|
||||
// In unified view route the write to the email's own account, reached
|
||||
// through the login it is reachable via (`sourceClientAccountId`) and
|
||||
// applied to its owning JMAP account (`sourceAccountId`). For personal
|
||||
// sources these resolve to the account itself, so behavior is unchanged.
|
||||
// Without this, tags on shared/group-mailbox messages are written to the
|
||||
// reaching account and silently dropped by the server. (#281)
|
||||
const tagClientId = emailState.isUnifiedView ? email?.sourceClientAccountId : undefined;
|
||||
const tagAccountId = emailState.isUnifiedView ? email?.sourceAccountId : undefined;
|
||||
const tagClient = tagClientId
|
||||
? (useAuthStore.getState().getClientForAccount(tagClientId) ?? client)
|
||||
: client;
|
||||
|
||||
await tagClient.updateEmailKeywords(emailId, keywords, tagAccountId);
|
||||
}
|
||||
|
||||
// Refresh the email list
|
||||
|
||||
Reference in New Issue
Block a user