Files
cinny/src/app/features/server-status/ServerStatusBanner.tsx
T
Lotus CIandClaude Opus 5.5 6a7627fa26
CI / Build & Quality Checks (pull_request) Successful in 1m48s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 12s
CI / Playwright smoke (e2e) (pull_request) Successful in 11m38s
feat: device-security nudge — verify this device / key backup (#110, #123)
One "security" strip, in the same top slot and style as the status banner
(#124), for this device, in priority order:
- "Verify this device" — cross-signing exists but this device isn't
  verified (can't unlock backed-up history, untrusted to others). First,
  because verifying with the recovery key also connects the backup.
- "Connect this device to your key backup" — a backup exists, this device
  isn't using it.
- "Set up key backup" — no backup at all (includes accounts without
  cross-signing; the setup flow does both).
The button opens Settings → Devices (existing flows); "Not now" snoozes.

Rules: nothing in a device's first 24 h; "Not now" snoozes that nudge 7
days; 3 dismissals stop it; never on a healthy device; waits until sync
has settled (never under "Connecting…"); outage/maintenance/connection
strips win. Per-device record in localStorage, wiped on logout. Mounted
inside the Matrix client context and an error boundary (an early version
outside the context crashed the app on load — caught before pushing).

Also: the status strip wraps its text on phones instead of truncating it
when there's no Details button (benefits #124's strips too).

Design approved on #123 (real-client screenshots there). Tests: 4 unit
(the #123 state table, loading, timing, stored record); e2e: nothing during
the grace period, "Set up key backup" → Settings → Devices, "Not now"
holds across a reload. Unit 1319, Playwright 29 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-30 20:28:32 -04:00

209 lines
7.0 KiB
TypeScript

import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { MatrixClient, SyncState } from 'matrix-js-sdk';
import { Box, Button, config, Icon, IconButton, Icons, IconSrc, Line, Text } from 'folds';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { useClientConfig } from '../../hooks/useClientConfig';
import { useSyncState } from '../../hooks/useSyncState';
import { useServerStatus } from '../../hooks/useServerStatus';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { serverStatusAtom } from '../../state/serverStatus';
import { pickBanner, resolveStatusPage, StatusBanner } from '../../utils/kumaStatus';
const DISMISS_KEY = 'lotus-status-banner-dismissed';
const DISMISS_MAX = 50;
const readDismissed = (): string[] => {
try {
const v = JSON.parse(localStorage.getItem(DISMISS_KEY) ?? '[]');
return Array.isArray(v) ? v.filter((k): k is string => typeof k === 'string') : [];
} catch {
return [];
}
};
const bannerIcon = (b: StripContent): IconSrc => {
if (b.kind === 'maintenance') return Icons.Setting;
if (b.tone === 'Primary') return Icons.Info;
return Icons.Warning;
};
/**
* [Gitea #124] One status strip, in the same slot and style as the sync and
* clock banners. Presentational: the caller picks the banner.
*/
export type StripContent = Pick<
StatusBanner,
'key' | 'tone' | 'text' | 'detail' | 'dismissable'
> & {
kind?: StatusBanner['kind'];
};
export function StatusStrip({
banner,
onDismiss,
icon,
action,
dismissLabel,
}: {
banner: StripContent;
onDismiss?: () => void;
/** Overrides the icon picked from the banner's tone/kind. */
icon?: IconSrc;
/** A primary action button (e.g. "Verify"). */
action?: { label: string; onClick: () => void };
/** A text button instead of the ✕ for dismissing (e.g. "Not now"). */
dismissLabel?: string;
}) {
const [open, setOpen] = useState(false);
const mobile = useScreenSizeContext() === ScreenSize.Mobile;
useEffect(() => setOpen(false), [banner.key]);
const critical = banner.tone === 'Critical';
return (
<Box direction="Column" shrink="No">
<Box
className={ContainerColor({ variant: banner.tone })}
style={{ padding: `${config.space.S100} ${config.space.S300}` }}
direction="Column"
gap="100"
role={critical ? 'alert' : 'status'}
aria-live={critical ? 'assertive' : 'polite'}
>
<Box
alignItems="Center"
justifyContent={mobile ? 'Start' : 'Center'}
gap="200"
style={{ minHeight: config.size.X300 }}
>
<Icon size="100" src={icon ?? bannerIcon(banner)} />
<Text size="L400" truncate={mobile && !!banner.detail && !open} style={{ minWidth: 0 }}>
{banner.text}
</Text>
{banner.detail && (
<Button
size="300"
variant={banner.tone}
fill="None"
radii="300"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
style={{ flexShrink: 0 }}
>
<Text size="B300" style={{ textDecoration: 'underline' }}>
{open ? 'Hide' : 'Details'}
</Text>
</Button>
)}
{action && (
<Button
size="300"
variant={banner.tone}
fill="Solid"
radii="300"
onClick={action.onClick}
style={{ flexShrink: 0 }}
>
<Text size="B300">{action.label}</Text>
</Button>
)}
{banner.dismissable && onDismiss && dismissLabel && (
<Button
size="300"
variant={banner.tone}
fill="None"
radii="300"
onClick={onDismiss}
style={{ flexShrink: 0 }}
>
<Text size="B300">{dismissLabel}</Text>
</Button>
)}
{banner.dismissable && onDismiss && !dismissLabel && (
<IconButton
size="300"
variant={banner.tone}
fill="None"
radii="300"
aria-label="Dismiss"
onClick={onDismiss}
style={{ flexShrink: 0 }}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)}
</Box>
{banner.detail && open && (
<Box justifyContent={mobile ? 'Start' : 'Center'}>
<Text size="T200" style={{ maxWidth: 680, whiteSpace: 'pre-line' }}>
{banner.detail}
</Text>
</Box>
)}
</Box>
<Line variant={banner.tone} size="300" />
</Box>
);
}
/** Runs the Kuma poller for the logged-in homeserver and publishes it. */
export function ServerStatusFeature({ mx }: { mx: MatrixClient }) {
const { statusPages } = useClientConfig();
const page = useMemo(
() => resolveStatusPage(statusPages, mx.getDomain() ?? undefined),
[statusPages, mx],
);
const [lostAt, setLostAt] = useState<number | null>(null);
useSyncState(
mx,
useCallback((state: SyncState | null) => {
const lost = state === SyncState.Reconnecting || state === SyncState.Error;
setLostAt((prev) => {
if (!lost) return null;
return prev ?? Date.now();
});
}, []),
);
const syncLost = lostAt !== null;
const status = useServerStatus(page, syncLost);
const setAtom = useSetAtom(serverStatusAtom);
useEffect(() => setAtom({ status, syncLost, lostAt }), [setAtom, status, syncLost, lostAt]);
return null;
}
/** The logged-in app's status strip. */
export function ServerStatusBanner() {
const { status, syncLost } = useAtomValue(serverStatusAtom);
const [dismissed, setDismissed] = useState(readDismissed);
const banner = pickBanner(status, {
syncLost,
where: 'client',
dismissed: new Set(dismissed),
});
if (!banner) return null;
const dismiss = () => {
const next = [...dismissed.filter((k) => k !== banner.key), banner.key].slice(-DISMISS_MAX);
try {
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
} catch {
// storage unavailable — dismissed for this session only
}
setDismissed(next);
};
return <StatusStrip banner={banner} onDismiss={dismiss} />;
}
/** The sign-in screen's strip, for the homeserver being signed in to. */
export function LoginStatusBanner({ serverName }: { serverName: string }) {
const { statusPages } = useClientConfig();
const page = useMemo(() => resolveStatusPage(statusPages, serverName), [statusPages, serverName]);
const status = useServerStatus(page);
const banner = pickBanner(status, { syncLost: false, where: 'login' });
return banner ? <StatusStrip banner={banner} /> : null;
}
/** Calls-down note for the call Join controls (null when calls are fine). */
export function useCallsDown(): boolean {
const { status } = useAtomValue(serverStatusAtom);
return status?.calls === 'down' && status.homeserver !== 'down';
}