diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index cca675d9..65bf03ed 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -428,9 +428,16 @@ export function Sidebar({ onUnreadFilterClick, className, }: SidebarProps) { + const router = useRouter(); const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore(); const { primaryIdentity: _primaryIdentity } = useAuthStore(); const [expandedFolders, setExpandedFolders] = useState>(new Set()); + const [foldersExpanded, setFoldersExpanded] = useState(() => { + try { + const stored = localStorage.getItem('sidebarFoldersExpanded'); + return stored !== null ? JSON.parse(stored) : true; + } catch { return true; } + }); const [tagsExpanded, setTagsExpanded] = useState(() => { try { const stored = localStorage.getItem('sidebarTagsExpanded'); @@ -554,24 +561,103 @@ export function Sidebar({ {/* Mailbox List */}
- {mailboxes.length === 0 ? ( -
- {!isCollapsed && t("loading_mailboxes")} -
- ) : ( + {/* Folders Section Header */} +
+ {!isCollapsed && ( + + )} + + + + {!isCollapsed && ( + + )} +
+ + {/* Folder Items */} + {((foldersExpanded && !isCollapsed) || isCollapsed) && ( <> - {mailboxTree.map((node) => ( - - ))} + {mailboxes.length === 0 ? ( +
+ {!isCollapsed && t("loading_mailboxes")} +
+ ) : ( + <> + {mailboxTree.map((node) => ( + + ))} + + )} )}
@@ -636,6 +722,20 @@ export function Sidebar({ {t("tags")} )} + + {!isCollapsed && ( + + )}
{((tagsExpanded && !isCollapsed) || isCollapsed) && ( diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 7087eec0..d4ac370a 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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 = { '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; } diff --git a/locales/en/common.json b/locales/en/common.json index 915043b1..43acd674 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -115,6 +115,7 @@ "demo_reset": "Reset", "demo_tour": "Tour", "tags": "Tags", + "folders": "Folders", "mail": "Mail", "nav_label": "Navigation", "add_app": "Apps"