diff --git a/web/src/jmap/push.ts b/web/src/jmap/push.ts index 23fe199..63ab75e 100644 --- a/web/src/jmap/push.ts +++ b/web/src/jmap/push.ts @@ -2,6 +2,9 @@ import type { Id, StateChange } from "./types"; export type PushListener = (accountId: Id, type: string, newState: string) => void; +/** Connected, trying to connect, or not trying. */ +export type PushState = "connected" | "connecting" | "disconnected"; + /** * JMAP push over Server-Sent Events (proxied through our server). * Emits per-type state changes so stores can refresh incrementally. @@ -9,12 +12,17 @@ export type PushListener = (accountId: Id, type: string, newState: string) => vo class PushManager { private es: EventSource | null = null; private listeners = new Set(); - private connectionListeners = new Set<(connected: boolean) => void>(); + private connectionListeners = new Set<(state: PushState) => void>(); private backoff = 1000; private reconnectTimer: number | null = null; private stopped = true; private lastStates = new Map(); connected = false; + /** + * Finer than `connected`, which cannot tell "trying" from "given up". + * "connecting" covers the first attempt and every backoff retry. + */ + state: PushState = "disconnected"; start(): void { this.stopped = false; @@ -31,7 +39,7 @@ class PushManager { this.reconnectTimer = null; this.es?.close(); this.es = null; - this.setConnected(false); + this.setState("disconnected"); } subscribe(fn: PushListener): () => void { @@ -39,14 +47,15 @@ class PushManager { return () => this.listeners.delete(fn); } - onConnection(fn: (connected: boolean) => void): () => void { + onConnection(fn: (state: PushState) => void): () => void { this.connectionListeners.add(fn); return () => this.connectionListeners.delete(fn); } - private setConnected(v: boolean) { - if (this.connected === v) return; - this.connected = v; + private setState(v: PushState) { + if (this.state === v) return; + this.state = v; + this.connected = v === "connected"; for (const fn of this.connectionListeners) fn(v); } @@ -60,12 +69,13 @@ class PushManager { private connect(): void { if (this.stopped || this.es) return; + if (this.state !== "connected") this.setState("connecting"); const url = `/api/events?types=*&closeafter=no&ping=30`; const es = new EventSource(url, { withCredentials: true }); this.es = es; es.onopen = () => { this.backoff = 1000; - this.setConnected(true); + this.setState("connected"); }; es.addEventListener("state", (ev) => { try { @@ -89,8 +99,9 @@ class PushManager { es.onerror = () => { es.close(); this.es = null; - this.setConnected(false); - if (this.stopped) return; + if (this.stopped) { this.setState("disconnected"); return; } + // A retry is already scheduled below, so this is "trying", not "given up". + this.setState("connecting"); const delay = Math.min(this.backoff, 60_000); this.backoff = Math.min(this.backoff * 2, 60_000); this.reconnectTimer = window.setTimeout(() => { diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 7b528f9..bd79cf5 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -1,7 +1,7 @@ import { create } from "zustand"; import { apiFetch, ApiError, CAP, client } from "@/jmap/client"; import type { Id, JmapSession } from "@/jmap/types"; -import { push } from "@/jmap/push"; +import { push, type PushState } from "@/jmap/push"; import { setServerLocale } from "@/lib/datetime"; export type AuthStatus = "loading" | "anonymous" | "authenticated"; @@ -13,6 +13,8 @@ interface SessionState { accountId: Id | null; error: string | null; pushConnected: boolean; + /** Finer than pushConnected: tells "reconnecting" from "not connected". */ + pushState: PushState; bootstrap(): Promise; login(username: string, password: string, totp: string, remember: boolean): Promise; logout(): Promise; @@ -28,6 +30,7 @@ export const useSession = create((set, get) => ({ accountId: null, error: null, pushConnected: false, + pushState: "disconnected", async bootstrap() { try { @@ -97,7 +100,7 @@ client.onUnauthenticated(() => { useSession.setState({ status: "anonymous", session: null, accountId: null }); }); -push.onConnection((connected) => useSession.setState({ pushConnected: connected })); +push.onConnection((state) => useSession.setState({ pushConnected: state === "connected", pushState: state })); export function hasCap(cap: string): boolean { return client.hasCapability(cap); diff --git a/web/src/styles/app.css b/web/src/styles/app.css index 3c99413..73c3cc4 100644 --- a/web/src/styles/app.css +++ b/web/src/styles/app.css @@ -302,8 +302,28 @@ img { max-width: 100%; } .search-panel .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; } .search-suggest { position: absolute; top: calc(100% + 6px); left: 0; right: 0; background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow-2); padding: 6px; z-index: 60; } .topbar-actions { display: flex; align-items: center; gap: 4px; } -.push-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg-faint); } -.push-dot.on { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); } +/* Live-updates indicator. Three states, and a raised bead rather than a flat + speck: at 8px flat it was invisible against either theme. The gloss is a + highlight over a solid colour rather than a colour-mix, so it needs no + per-theme variant -- the same bead reads on light and dark alike. */ +.push-status { display: inline-flex; align-items: center; padding: 0 4px; } +.push-dot { + width: 12px; + height: 12px; + border-radius: 50%; + background: + radial-gradient(circle at 34% 30%, rgba(255, 255, 255, .8), rgba(255, 255, 255, 0) 46%), + var(--dot); + box-shadow: + 0 0 0 3px var(--dot-halo), + 0 1px 2px rgba(0, 0, 0, .45), + inset 0 -1px 2px rgba(0, 0, 0, .3); +} +.push-dot.connected { --dot: #16a34a; --dot-halo: rgba(22, 163, 74, .25); } +.push-dot.connecting { --dot: #eab308; --dot-halo: rgba(234, 179, 8, .25); animation: push-pulse 1.6s ease-in-out infinite; } +.push-dot.disconnected { --dot: #dc2626; --dot-halo: rgba(220, 38, 38, .25); } +@keyframes push-pulse { 50% { opacity: .45; } } +@media (prefers-reduced-motion: reduce) { .push-dot.connecting { animation: none; } } .app-body { display: grid; grid-template-columns: var(--sidebar-w) 1fr; min-height: 0; transition: grid-template-columns .2s var(--ease); } .app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) 1fr; } diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index 5338fe0..5fbeb25 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -14,6 +14,12 @@ import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts"; import { formatSize } from "@/lib/format"; import { CAP } from "@/jmap/client"; +const PUSH_LABEL = { + connected: "Live updates connected", + connecting: "Live updates reconnecting…", + disconnected: "Live updates off — checking periodically instead", +} as const; + export function AppShell({ children }: { children: ReactNode }) { const [location, navigate] = useLocation(); const isMobile = useIsMobile(); @@ -22,7 +28,7 @@ export function AppShell({ children }: { children: ReactNode }) { const [drawer, setDrawer] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const openCompose = useCompose((s) => s.open); - const pushConnected = useSession((s) => s.pushConnected); + const pushState = useSession((s) => s.pushState); const session = useSession((s) => s.session); const accountId = useSession((s) => s.accountId); const setAccount = useSession((s) => s.setAccount); @@ -64,8 +70,8 @@ export function AppShell({ children }: { children: ReactNode }) {
-