diff --git a/app/(main)/[locale]/pro/page.tsx b/app/(main)/[locale]/pro/page.tsx index e709100e4..6c6cc861c 100644 --- a/app/(main)/[locale]/pro/page.tsx +++ b/app/(main)/[locale]/pro/page.tsx @@ -12,6 +12,7 @@ import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useDeviceDetection } from "@/hooks/use-media-query"; import { EmbeddedContext } from "@/hooks/use-is-embedded"; +import { OrgAnnouncement } from "@/components/org/org-announcement"; import { PaneSizeContext } from "@/hooks/use-pane-size"; import { PaneIdContext, ProTabFocusContext } from "@/hooks/use-pane-context"; import { useDeepLinkUrl } from "@/hooks/use-deep-link-url"; @@ -511,6 +512,8 @@ export default function ProHome() { return (
+ {/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */} +
{/* Leftmost Navigation Rail - identical to the standard layout */}
The end time has passed, so nobody sees it.

}

Times are in your browser's time zone. Open tabs show and hide the announcement on time without a reload.

- + set({ dismissible: v })} testId="org-announcement-dismissible" /> diff --git a/components/org/__tests__/org-announcement.test.tsx b/components/org/__tests__/org-announcement.test.tsx index 7e1b41bc1..0ff9a8f1e 100644 --- a/components/org/__tests__/org-announcement.test.tsx +++ b/components/org/__tests__/org-announcement.test.tsx @@ -40,12 +40,18 @@ describe('OrgAnnouncement', () => { expect(screen.queryByTestId('org-announcement')).toBeNull(); }); - it('shows the text with the critical role for a critical announcement', () => { + it('shows the text, and reads a critical one out as an alert after it is in the page', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); setAnnouncement({ severity: 'critical' }); render(); const banner = screen.getByTestId('org-announcement'); expect(banner.textContent).toContain('Mail is down tonight'); - expect(banner.getAttribute('role')).toBe('alert'); + const live = screen.getByTestId('org-announcement-live'); + expect(live.getAttribute('role')).toBe('alert'); + // Empty when it appears, filled a moment later: only a change is announced. + expect(live.textContent).toBe(''); + act(() => { vi.advanceTimersByTime(200); }); + expect(live.textContent).toBe('severity_critical: Mail is down tonight'); }); it('goes away at its end time without a reload', () => { @@ -58,6 +64,18 @@ describe('OrgAnnouncement', () => { expect(screen.queryByTestId('org-announcement')).toBeNull(); }); + it('shows one that arrives with a refetch after its start, in a tab open since before', () => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); + vi.setSystemTime(new Date('2026-11-02T08:00:00Z')); + usePolicyStore.setState({ policy: { ...DEFAULT_POLICY } }); + render(); + expect(screen.queryByTestId('org-announcement')).toBeNull(); + // Two hours later a refetch brings an announcement that started at 09:00. + vi.setSystemTime(new Date('2026-11-02T10:05:00Z')); + act(() => setAnnouncement({ id: 'n-late', startsAt: '2026-11-02T09:00:00.000Z', endsAt: '2026-11-02T12:00:00.000Z' })); + expect(screen.getByTestId('org-announcement')).toBeTruthy(); + }); + it('appears at its start time without a reload', () => { vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date'] }); vi.setSystemTime(new Date('2026-11-02T07:59:00Z')); @@ -100,9 +118,10 @@ describe('OrgAnnouncement', () => { it('is read out once per page load, then is a plain region', () => { setAnnouncement({ id: 'n-once' }); const { unmount } = render(); - expect(screen.getByTestId('org-announcement').getAttribute('role')).toBe('status'); + expect(screen.getByTestId('org-announcement').getAttribute('role')).toBe('region'); + expect(screen.getByTestId('org-announcement-live').getAttribute('role')).toBe('status'); unmount(); render(); - expect(screen.getByTestId('org-announcement').getAttribute('role')).toBe('region'); + expect(screen.queryByTestId('org-announcement-live')).toBeNull(); }); }); diff --git a/components/org/org-announcement.tsx b/components/org/org-announcement.tsx index 286f0c123..2c7933013 100644 --- a/components/org/org-announcement.tsx +++ b/components/org/org-announcement.tsx @@ -36,16 +36,40 @@ const STYLES = { const ICONS = { info: Info, warning: AlertTriangle, critical: AlertCircle } as const; -/** Re-renders when the announcement starts or ends. */ -function useNow(change: number | null): number { - const [now, setNow] = useState(() => Date.now()); +/** + * The time now, read on every render (an announcement that arrives with a + * policy refetch is judged by the real time), plus a re-render when the + * announcement starts or ends. + */ +function useClock(change: number | null): number { + const [tick, setTick] = useState(0); useEffect(() => { if (change === null) return; const wait = Math.min(MAX_TIMER_MS, Math.max(0, change - Date.now()) + 50); - const timer = setTimeout(() => setNow(Date.now()), wait); + const timer = setTimeout(() => setTick((n) => n + 1), wait); return () => clearTimeout(timer); - }, [change, now]); - return now; + }, [change, tick]); + return Date.now(); +} + +/** + * Reads the announcement out once: a live region that is in the page empty + * and gets the text a moment later (text already there when a live region + * appears isn't announced reliably), then empties again so browsing the + * page doesn't meet it twice. + */ +function LiveOnce({ text, assertive }: { text: string; assertive: boolean }) { + const [said, setSaid] = useState(''); + useEffect(() => { + const fill = setTimeout(() => setSaid(text), 150); + const clear = setTimeout(() => setSaid(''), 15_000); + return () => { clearTimeout(fill); clearTimeout(clear); }; + }, [text]); + return ( +
+ {said} +
+ ); } function openLink(event: MouseEvent, href: string) { @@ -71,7 +95,7 @@ export function OrgAnnouncement() { const [dismissedKey, setDismissedKey] = useState(null); const text = announcement ? announcementText(announcement, locale) : ''; - const now = useNow(text ? nextAnnouncementChange(announcement, Date.now()) : null); + const now = useClock(text ? nextAnnouncementChange(announcement, Date.now()) : null); const key = announcement && text ? announcementKey(announcement) : null; const active = !!key && announcementActive(announcement, now); const dismissed = !!key && !!announcement?.dismissible @@ -87,19 +111,20 @@ export function OrgAnnouncement() { if (!visible || !announcement) return null; const severity = announcement.severity in STYLES ? announcement.severity : 'info'; const Icon = ICONS[severity]; - const role = firstShow ? (severity === 'critical' ? 'alert' : 'status') : 'region'; + const severityLabel = t(`severity_${severity}`); return (
+ {firstShow && }