Merge pull request #44 from LINUXexpert-org/push-dot-and-menu-close

Close the colour menu on a pick, and make the push dot readable
This commit is contained in:
LINUXexpert.org
2026-08-25 13:18:20 -07:00
committed by GitHub
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;
/** 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<PushListener>();
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<string, string>();
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(() => {
+5 -2
View File
@@ -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<void>;
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
logout(): Promise<void>;
@@ -28,6 +30,7 @@ export const useSession = create<SessionState>((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);
+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-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; }
+9 -3
View File
@@ -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 }) {
</Link>
<SearchBar />
<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-dot ${pushConnected ? "on" : ""}`} />
<span className="push-status hide-mobile" role="img" aria-label={PUSH_LABEL[pushState]} title={PUSH_LABEL[pushState]}>
<span className={`push-dot ${pushState}`} />
</span>
<button className="icon-btn hide-mobile" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" onClick={() => setHelpOpen(true)}>
<HelpCircle size={21} />
+3 -2
View File
@@ -183,7 +183,7 @@ export function MailboxTree() {
)}
</nav>
<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>
{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 colors = useSettings((s) => s.settings.folderColors);
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 color = folderColor(colors, m.id);
const setColor = (c: string | null) => {
onClose();
const next = { ...colors };
if (c) next[m.id] = c;
else delete next[m.id];