fix: show an announcement that arrives after its start, once in the Pro shell, and keep it closed when only its times move

- The banner judged its start and end by the time it was mounted, so an
  announcement fetched later than its start never showed in that tab.
- The Pro shell shows it once above its panes; email and folder tabs had
  none and split panes showed it twice.
- Screen readers get it from a live region that fills a moment after it
  is in the page; text present when a live region appears isn't read out
  reliably. The tests that checked the strip's role now check that region.
- Moving the start or end no longer shows a closed announcement again;
  the lib test that expected a new end time to reset it now expects the
  opposite.
This commit is contained in:
Linus Rath
2026-11-01 09:39:36 +01:00
parent 5d48e2a4ef
commit 61733d839f
8 changed files with 88 additions and 23 deletions
+3
View File
@@ -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 (
<EmbeddedContext.Provider value={true}>
<div className="flex flex-col h-dvh bg-background overflow-hidden pt-[env(safe-area-inset-top)]">
{/* Once for the whole shell: email and folder tabs have no app strip, and split panes would show it twice. */}
<OrgAnnouncement />
<div className="flex flex-1 overflow-hidden">
{/* Leftmost Navigation Rail - identical to the standard layout */}
<div
@@ -280,7 +280,7 @@ export function AnnouncementSection({ draft }: { draft: OrgPolicyDraft }) {
{!badRange && ended && <p className="text-xs text-amber-700 dark:text-amber-300">The end time has passed, so nobody sees it.</p>}
<p className="text-xs text-muted-foreground">Times are in your browser&apos;s time zone. Open tabs show and hide the announcement on time without a reload.</p>
<SettingItem label="Users can close it" description="Closed stays closed for that account in that browser until you change the announcement. Turn this off for messages everyone must see.">
<SettingItem label="Users can close it" description="Closed stays closed for that account in that browser until you change its text or severity; moving the start or end doesn't show it again. Turn this off for messages everyone must see.">
<ToggleSwitch checked={announcement.dismissible} onChange={(v) => set({ dismissible: v })} testId="org-announcement-dismissible" />
</SettingItem>
@@ -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(<OrgAnnouncement />);
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(<OrgAnnouncement />);
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(<OrgAnnouncement />);
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(<OrgAnnouncement />);
expect(screen.getByTestId('org-announcement').getAttribute('role')).toBe('region');
expect(screen.queryByTestId('org-announcement-live')).toBeNull();
});
});
+36 -11
View File
@@ -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 (
<div role={assertive ? 'alert' : 'status'} className="sr-only" data-testid="org-announcement-live">
{said}
</div>
);
}
function openLink(event: MouseEvent<HTMLAnchorElement>, href: string) {
@@ -71,7 +95,7 @@ export function OrgAnnouncement() {
const [dismissedKey, setDismissedKey] = useState<string | null>(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 (
<div
role={role}
aria-label={role === 'region' ? t('label') : undefined}
role="region"
aria-label={t('label')}
data-testid="org-announcement"
data-severity={severity}
className={cn('flex shrink-0 items-start gap-2 border-b px-3 text-sm sm:px-4', STYLES[severity])}
>
{firstShow && <LiveOnce text={`${severityLabel}: ${text}`} assertive={severity === 'critical'} />}
<Icon className="mt-3 h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 whitespace-pre-line break-words py-2.5 leading-snug [overflow-wrap:anywhere]">
<span className="sr-only">{t(`severity_${severity}`)}: </span>
<span className="sr-only">{severityLabel}: </span>
{parts.map((part, index) => part.kind === 'link' ? (
<a
key={index}
@@ -14,6 +14,7 @@ vi.mock('@/components/plugins/plugin-slot', () => ({
}));
import { AppTopBannerSlot } from '@/components/plugins/app-top-banner-slot';
import { EmbeddedContext } from '@/hooks/use-is-embedded';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -46,4 +47,10 @@ describe('AppTopBannerSlot', () => {
render(<AppTopBannerSlot />);
expect(screen.getByTestId('org-announcement')).toBeTruthy();
});
it('leaves the announcement to the Pro shell, which shows it once for all its panes', () => {
useAccountStore.setState({ accounts: [account], activeAccountId: account.id });
render(<EmbeddedContext.Provider value={true}><AppTopBannerSlot /></EmbeddedContext.Provider>);
expect(screen.queryByTestId('org-announcement')).toBeNull();
});
});
+5 -2
View File
@@ -2,6 +2,7 @@
import { PluginSlot } from '@/components/plugins/plugin-slot';
import { OrgAnnouncement } from '@/components/org/org-announcement';
import { useIsEmbedded } from '@/hooks/use-is-embedded';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -11,17 +12,19 @@ import { useAuthStore } from '@/stores/auth-store';
* impersonation handoff (`impersonated`). Drop this at the top of every
* authenticated page so plugins like impersonation-notice render
* everywhere, not just on the mail page. The organisation's announcement
* (Organisation > Notices) shows here too, above the plugins' banners.
* (Organisation > Notices) shows here too, above the plugins' banners,
* except inside the Pro shell, which shows it once across all its panes.
*/
export function AppTopBannerSlot() {
const username = useAuthStore((s) => s.username);
const serverUrl = useAuthStore((s) => s.serverUrl);
const embedded = useIsEmbedded();
const impersonated = useAccountStore(
(s) => s.accounts.find((a) => a.id === s.activeAccountId)?.impersonated === true,
);
return (
<>
<OrgAnnouncement />
{!embedded && <OrgAnnouncement />}
<PluginSlot name="app-top-banner" extraProps={{ username, serverUrl, impersonated }} />
</>
);
+8 -2
View File
@@ -67,13 +67,19 @@ describe('dismissal', () => {
expect(isAnnouncementDismissed(base, 'acc-2')).toBe(false);
});
it('no longer holds once the admin edits the text, or for a new announcement', () => {
it('no longer holds once the admin edits the text or severity, or for a new announcement', () => {
dismissAnnouncement(base, 'acc-1');
expect(isAnnouncementDismissed({ ...base, text: { ...base.text, en: 'Maintenance moved to Friday' } }, 'acc-1')).toBe(false);
expect(isAnnouncementDismissed({ ...base, endsAt: '2026-12-01T00:00:00.000Z' }, 'acc-1')).toBe(false);
expect(isAnnouncementDismissed({ ...base, severity: 'critical' }, 'acc-1')).toBe(false);
expect(isAnnouncementDismissed({ ...base, id: 'a2' }, 'acc-1')).toBe(false);
});
it('still holds when only the start or end time moves', () => {
dismissAnnouncement(base, 'acc-1');
expect(isAnnouncementDismissed({ ...base, endsAt: '2026-12-01T00:00:00.000Z' }, 'acc-1')).toBe(true);
expect(isAnnouncementDismissed({ ...base, startsAt: '2026-11-01T00:00:00.000Z' }, 'acc-1')).toBe(true);
});
it('does not depend on the order of the languages', () => {
const reordered = { ...base, text: { 'pt-BR': base.text['pt-BR'], de: base.text.de, en: base.text.en } };
expect(announcementKey(reordered)).toBe(announcementKey(base));
+5 -3
View File
@@ -63,13 +63,15 @@ export function nextAnnouncementChange(announcement: Announcement | undefined |
return times.length > 0 ? Math.min(...times) : null;
}
/** FNV-1a over the content an admin edits: a new text, time or severity is a new announcement for dismissals. */
/**
* FNV-1a over what the message says: a new text or severity is a new
* announcement for dismissals. Moving its start or end isn't; whoever
* closed it keeps it closed.
*/
export function announcementHash(announcement: Announcement): string {
const content = JSON.stringify([
Object.entries(announcement.text ?? {}).sort(([a], [b]) => a.localeCompare(b)),
announcement.severity,
announcement.startsAt,
announcement.endsAt,
]);
let hash = 0x811c9dc5;
for (let i = 0; i < content.length; i++) {