feat: add folder expansion state management and settings navigation

This commit is contained in:
Linus Rath
2026-03-28 22:32:01 +01:00
parent c7c22bd210
commit 1ba8e2fd47
3 changed files with 191 additions and 17 deletions
+73
View File
@@ -3907,6 +3907,10 @@ export class JMAPClient implements IJMAPClient {
private pollingStates: { [key: string]: string } = {};
private sseAbortController: AbortController | null = null;
private sseReconnectTimeout: NodeJS.Timeout | null = null;
private ssePingTimer: NodeJS.Timeout | null = null;
private lastSSEActivity: number = 0;
private visibilityHandler: (() => void) | null = null;
private onlineHandler: (() => void) | null = null;
private static readonly STATE_TYPE_MAP: Record<string, string> = {
'Mailbox/get': 'Mailbox',
@@ -3918,6 +3922,7 @@ export class JMAPClient implements IJMAPClient {
private static readonly POLLING_INTERVAL = 3_000;
private static readonly SSE_RECONNECT_DELAY = 3_000;
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
setupPushNotifications(): boolean {
const eventSourceUrl = this.getEventSourceUrl();
@@ -3926,6 +3931,7 @@ export class JMAPClient implements IJMAPClient {
} else {
this.startPollingFallback();
}
this.setupBrowserEventListeners();
return true;
}
@@ -3966,11 +3972,16 @@ export class JMAPClient implements IJMAPClient {
const decoder = new TextDecoder();
let buffer = '';
this.lastSSEActivity = Date.now();
this.startSSEPingMonitor();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
this.lastSSEActivity = Date.now();
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n\n');
buffer = parts.pop() || '';
@@ -3983,6 +3994,8 @@ export class JMAPClient implements IJMAPClient {
if (error instanceof DOMException && error.name === 'AbortError') return;
}
this.stopSSEPingMonitor();
// Stream ended — reconnect unless we were intentionally closed
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
this.scheduleSSEReconnect();
@@ -4155,10 +4168,70 @@ export class JMAPClient implements IJMAPClient {
this.eventSource.close();
this.eventSource = null;
}
this.stopSSEPingMonitor();
this.cleanupBrowserEventListeners();
this.stateChangeCallback = null;
this.pollingStates = {};
}
private startSSEPingMonitor(): void {
this.stopSSEPingMonitor();
this.ssePingTimer = setInterval(() => {
if (Date.now() - this.lastSSEActivity > JMAPClient.SSE_PING_TIMEOUT) {
// SSE connection is stale — abort and reconnect
this.stopSSEPingMonitor();
if (this.sseAbortController) {
this.sseAbortController.abort();
this.sseAbortController = null;
}
this.scheduleSSEReconnect();
}
}, 30_000);
}
private stopSSEPingMonitor(): void {
if (this.ssePingTimer) {
clearInterval(this.ssePingTimer);
this.ssePingTimer = null;
}
}
private setupBrowserEventListeners(): void {
if (typeof document !== 'undefined') {
this.visibilityHandler = () => {
if (!document.hidden) {
// Tab became visible — immediately check for state changes
this.checkForStateChanges();
}
};
document.addEventListener('visibilitychange', this.visibilityHandler);
}
if (typeof window !== 'undefined') {
this.onlineHandler = () => {
// Network reconnected — reconnect SSE or force a poll
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl && !this.sseAbortController) {
this.connectSSE(eventSourceUrl);
} else {
this.checkForStateChanges();
}
};
window.addEventListener('online', this.onlineHandler);
}
}
private cleanupBrowserEventListeners(): void {
if (this.visibilityHandler && typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.visibilityHandler);
this.visibilityHandler = null;
}
if (this.onlineHandler && typeof window !== 'undefined') {
window.removeEventListener('online', this.onlineHandler);
this.onlineHandler = null;
}
}
onConnectionChange(callback: (connected: boolean) => void): void {
this.connectionChangeCallback = callback;
}