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:
Maarten Draijer
2026-07-22 02:54:55 +00:00
co-authored by Claude Fable 5
298 changed files with 25468 additions and 2440 deletions
+559
View File
@@ -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();
});
});
});
+251
View File
@@ -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;
}
+16
View File
@@ -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,
+237
View File
@@ -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]);
}
+19 -4
View File
@@ -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]
);
}
+27 -13
View File
@@ -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
View File
@@ -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