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:
+20
-9
@@ -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(() => {
|
||||||
|
|||||||
@@ -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
@@ -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; }
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
Reference in New Issue
Block a user