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.
This commit is contained in:
2026-08-25 13:15:56 -07:00
parent 5499971c54
commit 732fdac78b
5 changed files with 59 additions and 18 deletions
+20 -9
View File
@@ -2,6 +2,9 @@ import type { Id, StateChange } from "./types";
export type PushListener = (accountId: Id, type: string, newState: string) => void; 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). * JMAP push over Server-Sent Events (proxied through our server).
* Emits per-type state changes so stores can refresh incrementally. * 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 { class PushManager {
private es: EventSource | null = null; private es: EventSource | null = null;
private listeners = new Set<PushListener>(); private listeners = new Set<PushListener>();
private connectionListeners = new Set<(connected: boolean) => void>(); private connectionListeners = new Set<(state: PushState) => void>();
private backoff = 1000; private backoff = 1000;
private reconnectTimer: number | null = null; private reconnectTimer: number | null = null;
private stopped = true; private stopped = true;
private lastStates = new Map<string, string>(); private lastStates = new Map<string, string>();
connected = false; 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 { start(): void {
this.stopped = false; this.stopped = false;
@@ -31,7 +39,7 @@ class PushManager {
this.reconnectTimer = null; this.reconnectTimer = null;
this.es?.close(); this.es?.close();
this.es = null; this.es = null;
this.setConnected(false); this.setState("disconnected");
} }
subscribe(fn: PushListener): () => void { subscribe(fn: PushListener): () => void {
@@ -39,14 +47,15 @@ class PushManager {
return () => this.listeners.delete(fn); return () => this.listeners.delete(fn);
} }
onConnection(fn: (connected: boolean) => void): () => void { onConnection(fn: (state: PushState) => void): () => void {
this.connectionListeners.add(fn); this.connectionListeners.add(fn);
return () => this.connectionListeners.delete(fn); return () => this.connectionListeners.delete(fn);
} }
private setConnected(v: boolean) { private setState(v: PushState) {
if (this.connected === v) return; if (this.state === v) return;
this.connected = v; this.state = v;
this.connected = v === "connected";
for (const fn of this.connectionListeners) fn(v); for (const fn of this.connectionListeners) fn(v);
} }
@@ -60,12 +69,13 @@ class PushManager {
private connect(): void { private connect(): void {
if (this.stopped || this.es) return; if (this.stopped || this.es) return;
if (this.state !== "connected") this.setState("connecting");
const url = `/api/events?types=*&closeafter=no&ping=30`; const url = `/api/events?types=*&closeafter=no&ping=30`;
const es = new EventSource(url, { withCredentials: true }); const es = new EventSource(url, { withCredentials: true });
this.es = es; this.es = es;
es.onopen = () => { es.onopen = () => {
this.backoff = 1000; this.backoff = 1000;
this.setConnected(true); this.setState("connected");
}; };
es.addEventListener("state", (ev) => { es.addEventListener("state", (ev) => {
try { try {
@@ -89,8 +99,9 @@ class PushManager {
es.onerror = () => { es.onerror = () => {
es.close(); es.close();
this.es = null; this.es = null;
this.setConnected(false); if (this.stopped) { this.setState("disconnected"); return; }
if (this.stopped) 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); const delay = Math.min(this.backoff, 60_000);
this.backoff = Math.min(this.backoff * 2, 60_000); this.backoff = Math.min(this.backoff * 2, 60_000);
this.reconnectTimer = window.setTimeout(() => { this.reconnectTimer = window.setTimeout(() => {
+5 -2
View File
@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { apiFetch, ApiError, CAP, client } from "@/jmap/client"; import { apiFetch, ApiError, CAP, client } from "@/jmap/client";
import type { Id, JmapSession } from "@/jmap/types"; import type { Id, JmapSession } from "@/jmap/types";
import { push } from "@/jmap/push"; import { push, type PushState } from "@/jmap/push";
import { setServerLocale } from "@/lib/datetime"; import { setServerLocale } from "@/lib/datetime";
export type AuthStatus = "loading" | "anonymous" | "authenticated"; export type AuthStatus = "loading" | "anonymous" | "authenticated";
@@ -13,6 +13,8 @@ interface SessionState {
accountId: Id | null; accountId: Id | null;
error: string | null; error: string | null;
pushConnected: boolean; pushConnected: boolean;
/** Finer than pushConnected: tells "reconnecting" from "not connected". */
pushState: PushState;
bootstrap(): Promise<void>; bootstrap(): Promise<void>;
login(username: string, password: string, totp: string, remember: boolean): Promise<void>; login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
logout(): Promise<void>; logout(): Promise<void>;
@@ -28,6 +30,7 @@ export const useSession = create<SessionState>((set, get) => ({
accountId: null, accountId: null,
error: null, error: null,
pushConnected: false, pushConnected: false,
pushState: "disconnected",
async bootstrap() { async bootstrap() {
try { try {
@@ -97,7 +100,7 @@ client.onUnauthenticated(() => {
useSession.setState({ status: "anonymous", session: null, accountId: null }); 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 { export function hasCap(cap: string): boolean {
return client.hasCapability(cap); return client.hasCapability(cap);
+22 -2
View File
@@ -302,8 +302,28 @@ img { max-width: 100%; }
.search-panel .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; } .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; } .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; } .topbar-actions { display: flex; align-items: center; gap: 4px; }
.push-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg-faint); } /* Live-updates indicator. Three states, and a raised bead rather than a flat
.push-dot.on { background: var(--success); box-shadow: 0 0 0 3px var(--success-soft); } 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 { 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; } .app-body.collapsed { grid-template-columns: var(--sidebar-w-collapsed) 1fr; }
+9 -3
View File
@@ -14,6 +14,12 @@ import { ShortcutsDialog, useGlobalShortcuts } from "./Shortcuts";
import { formatSize } from "@/lib/format"; import { formatSize } from "@/lib/format";
import { CAP } from "@/jmap/client"; 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 }) { export function AppShell({ children }: { children: ReactNode }) {
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -22,7 +28,7 @@ export function AppShell({ children }: { children: ReactNode }) {
const [drawer, setDrawer] = useState(false); const [drawer, setDrawer] = useState(false);
const [helpOpen, setHelpOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false);
const openCompose = useCompose((s) => s.open); 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 session = useSession((s) => s.session);
const accountId = useSession((s) => s.accountId); const accountId = useSession((s) => s.accountId);
const setAccount = useSession((s) => s.setAccount); const setAccount = useSession((s) => s.setAccount);
@@ -64,8 +70,8 @@ export function AppShell({ children }: { children: ReactNode }) {
</Link> </Link>
<SearchBar /> <SearchBar />
<div className="topbar-actions"> <div className="topbar-actions">
<span className="push-dot hide-mobile" title={pushConnected ? "Live updates connected" : "Live updates disconnected (polling)"} aria-hidden="true"> <span className="push-status hide-mobile" role="img" aria-label={PUSH_LABEL[pushState]} title={PUSH_LABEL[pushState]}>
<span className={`push-dot ${pushConnected ? "on" : ""}`} /> <span className={`push-dot ${pushState}`} />
</span> </span>
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}> <button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
<HelpCircle size={21} /> <HelpCircle size={21} />
+3 -2
View File
@@ -183,7 +183,7 @@ export function MailboxTree() {
)} )}
</nav> </nav>
<Popover anchor={menu.anchor} onClose={menu.close} width={300}> <Popover anchor={menu.anchor} onClose={menu.close} width={300}>
{menuTarget && <MailboxMenu mailbox={menuTarget} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />} {menuTarget && <MailboxMenu mailbox={menuTarget} onClose={menu.close} onCreateChild={() => void createFolder(menuTarget.id)} onShare={() => setShareTarget(menuTarget)} />}
</Popover> </Popover>
{shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />} {shareTarget && <ShareDialog kind="Mailbox" id={shareTarget.id} name={shareTarget.name} shareWith={shareTarget.shareWith ?? null} onClose={() => setShareTarget(null)} />}
</> </>
@@ -290,7 +290,7 @@ function FolderRow({ mailbox: m, label, depth, hasChildren, open, hiddenUnread,
); );
} }
function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox; onCreateChild: () => void; onShare: () => void }) { function MailboxMenu({ mailbox: m, onClose, onCreateChild, onShare }: { mailbox: Mailbox; onClose: () => void; onCreateChild: () => void; onShare: () => void }) {
const [, navigate] = useLocation(); const [, navigate] = useLocation();
const colors = useSettings((s) => s.settings.folderColors); const colors = useSettings((s) => s.settings.folderColors);
const update = useSettings((s) => s.update); const update = useSettings((s) => s.update);
@@ -335,6 +335,7 @@ function MailboxMenu({ mailbox: m, onCreateChild, onShare }: { mailbox: Mailbox;
const isSpecial = Boolean(m.role) && m.role !== "subscribed"; const isSpecial = Boolean(m.role) && m.role !== "subscribed";
const color = folderColor(colors, m.id); const color = folderColor(colors, m.id);
const setColor = (c: string | null) => { const setColor = (c: string | null) => {
onClose();
const next = { ...colors }; const next = { ...colors };
if (c) next[m.id] = c; if (c) next[m.id] = c;
else delete next[m.id]; else delete next[m.id];