mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
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:
@@ -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'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,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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
Reference in New Issue
Block a user