From 0dd0aed0dd8c471fb481bb380b144ad7128ac248 Mon Sep 17 00:00:00 2001 From: John Ellis Date: Tue, 25 Aug 2026 13:15:56 -0700 Subject: [PATCH] Close the colour menu on a pick, and make the push dot readable Two cosmetics. Picking a folder colour left the menu open, which every other action in it does not. It closes now, the way the calendar's colour menu already did. The live-updates indicator was an 8px flat speck in --fg-faint, near invisible in either theme, and it had two states where the code has three. The push client only ever said connected or not, which cannot tell "retrying with a backoff" from "stopped": it now reports connecting, connected or disconnected, and the retry path says connecting rather than going dark. pushConnected stays for the callers that only want the boolean. The dot is 12px and raised -- a white highlight over a solid colour with a soft halo, so one bead reads on light and dark alike without a per-theme variant. Green connected, amber reconnecting with a slow pulse, red disconnected. The pulse respects prefers-reduced-motion, and the indicator is labelled for a screen reader rather than hidden from it, since it carries real information. --- web/src/jmap/push.ts | 29 ++++++++++++++++++++--------- web/src/store/session.ts | 7 +++++-- web/src/styles/app.css | 24 ++++++++++++++++++++++-- web/src/views/AppShell.tsx | 12 +++++++++--- web/src/views/mail/MailboxTree.tsx | 5 +++-- 5 files changed, 59 insertions(+), 18 deletions(-) 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 }) {
-