feat: add folder expansion state management and settings navigation
This commit is contained in:
+117
-17
@@ -428,9 +428,16 @@ export function Sidebar({
|
|||||||
onUnreadFilterClick,
|
onUnreadFilterClick,
|
||||||
className,
|
className,
|
||||||
}: SidebarProps) {
|
}: SidebarProps) {
|
||||||
|
const router = useRouter();
|
||||||
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
const { sidebarCollapsed: isCollapsed, toggleSidebarCollapsed } = useUIStore();
|
||||||
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
const { primaryIdentity: _primaryIdentity } = useAuthStore();
|
||||||
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
|
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(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(() => {
|
const [tagsExpanded, setTagsExpanded] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem('sidebarTagsExpanded');
|
const stored = localStorage.getItem('sidebarTagsExpanded');
|
||||||
@@ -554,24 +561,103 @@ export function Sidebar({
|
|||||||
{/* Mailbox List */}
|
{/* Mailbox List */}
|
||||||
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
<div className="flex-1 overflow-y-auto" data-tour="sidebar">
|
||||||
<div className="py-1">
|
<div className="py-1">
|
||||||
{mailboxes.length === 0 ? (
|
{/* Folders Section Header */}
|
||||||
<div className="px-4 py-2 text-sm text-muted-foreground">
|
<div
|
||||||
{!isCollapsed && t("loading_mailboxes")}
|
style={{ paddingBlock: 'var(--density-sidebar-py)' }}
|
||||||
</div>
|
className={cn(
|
||||||
) : (
|
"group w-full flex items-center max-lg:min-h-[44px] text-sm transition-all duration-200 font-medium",
|
||||||
|
isCollapsed ? "justify-center px-1" : "px-2",
|
||||||
|
"text-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setFoldersExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"p-0.5 rounded mr-1 transition-all duration-200",
|
||||||
|
"hover:bg-muted active:bg-accent"
|
||||||
|
)}
|
||||||
|
title={foldersExpanded ? t('collapse_tooltip') : t('expand_tooltip')}
|
||||||
|
>
|
||||||
|
{foldersExpanded ? (
|
||||||
|
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (isCollapsed) return;
|
||||||
|
setFoldersExpanded((prev: boolean) => {
|
||||||
|
const next = !prev;
|
||||||
|
try { localStorage.setItem('sidebarFoldersExpanded', JSON.stringify(next)); } catch { /* */ }
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center px-1 rounded",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
isCollapsed ? "justify-center" : "flex-1 text-left"
|
||||||
|
)}
|
||||||
|
style={{ paddingBlock: 'var(--density-sidebar-py)', ...(isCollapsed ? {} : { paddingLeft: '4px' }) }}
|
||||||
|
title={isCollapsed ? t("folders") : undefined}
|
||||||
|
>
|
||||||
|
<Folder className={cn(
|
||||||
|
"w-4 h-4 flex-shrink-0 transition-colors",
|
||||||
|
!isCollapsed && "mr-2",
|
||||||
|
foldersExpanded && "text-primary"
|
||||||
|
)} />
|
||||||
|
{!isCollapsed && (
|
||||||
|
<span className="flex-1 truncate">{t("folders")}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'folders'); } catch { /* */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
|
||||||
|
title={t('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Folder Items */}
|
||||||
|
{((foldersExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
<>
|
<>
|
||||||
{mailboxTree.map((node) => (
|
{mailboxes.length === 0 ? (
|
||||||
<MailboxTreeItem
|
<div className="px-4 py-2 text-sm text-muted-foreground">
|
||||||
key={node.id}
|
{!isCollapsed && t("loading_mailboxes")}
|
||||||
node={node}
|
</div>
|
||||||
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
) : (
|
||||||
expandedFolders={expandedFolders}
|
<>
|
||||||
onMailboxSelect={onMailboxSelect}
|
{mailboxTree.map((node) => (
|
||||||
onToggleExpand={handleToggleExpand}
|
<MailboxTreeItem
|
||||||
isCollapsed={isCollapsed}
|
key={node.id}
|
||||||
onUnreadFilterClick={onUnreadFilterClick}
|
node={node}
|
||||||
/>
|
selectedMailbox={selectedKeyword ? "" : selectedMailbox}
|
||||||
))}
|
expandedFolders={expandedFolders}
|
||||||
|
onMailboxSelect={onMailboxSelect}
|
||||||
|
onToggleExpand={handleToggleExpand}
|
||||||
|
isCollapsed={isCollapsed}
|
||||||
|
onUnreadFilterClick={onUnreadFilterClick}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -636,6 +722,20 @@ export function Sidebar({
|
|||||||
<span className="flex-1 truncate">{t("tags")}</span>
|
<span className="flex-1 truncate">{t("tags")}</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{!isCollapsed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
try { localStorage.setItem('settings-active-tab', 'keywords'); } catch { /* */ }
|
||||||
|
router.push('/settings');
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity duration-150 hover:bg-muted active:bg-accent ml-auto mr-1"
|
||||||
|
title={t('settings')}
|
||||||
|
>
|
||||||
|
<Settings className="w-3 h-3 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
{((tagsExpanded && !isCollapsed) || isCollapsed) && (
|
||||||
|
|||||||
@@ -3907,6 +3907,10 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
private pollingStates: { [key: string]: string } = {};
|
private pollingStates: { [key: string]: string } = {};
|
||||||
private sseAbortController: AbortController | null = null;
|
private sseAbortController: AbortController | null = null;
|
||||||
private sseReconnectTimeout: NodeJS.Timeout | 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> = {
|
private static readonly STATE_TYPE_MAP: Record<string, string> = {
|
||||||
'Mailbox/get': 'Mailbox',
|
'Mailbox/get': 'Mailbox',
|
||||||
@@ -3918,6 +3922,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
|
|
||||||
private static readonly POLLING_INTERVAL = 3_000;
|
private static readonly POLLING_INTERVAL = 3_000;
|
||||||
private static readonly SSE_RECONNECT_DELAY = 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 {
|
setupPushNotifications(): boolean {
|
||||||
const eventSourceUrl = this.getEventSourceUrl();
|
const eventSourceUrl = this.getEventSourceUrl();
|
||||||
@@ -3926,6 +3931,7 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
} else {
|
} else {
|
||||||
this.startPollingFallback();
|
this.startPollingFallback();
|
||||||
}
|
}
|
||||||
|
this.setupBrowserEventListeners();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3966,11 +3972,16 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
|
|
||||||
|
this.lastSSEActivity = Date.now();
|
||||||
|
this.startSSEPingMonitor();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
|
this.lastSSEActivity = Date.now();
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true });
|
buffer += decoder.decode(value, { stream: true });
|
||||||
const parts = buffer.split('\n\n');
|
const parts = buffer.split('\n\n');
|
||||||
buffer = parts.pop() || '';
|
buffer = parts.pop() || '';
|
||||||
@@ -3983,6 +3994,8 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.stopSSEPingMonitor();
|
||||||
|
|
||||||
// Stream ended — reconnect unless we were intentionally closed
|
// Stream ended — reconnect unless we were intentionally closed
|
||||||
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
|
if (this.sseAbortController && !this.sseAbortController.signal.aborted) {
|
||||||
this.scheduleSSEReconnect();
|
this.scheduleSSEReconnect();
|
||||||
@@ -4155,10 +4168,70 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
this.eventSource.close();
|
this.eventSource.close();
|
||||||
this.eventSource = null;
|
this.eventSource = null;
|
||||||
}
|
}
|
||||||
|
this.stopSSEPingMonitor();
|
||||||
|
this.cleanupBrowserEventListeners();
|
||||||
this.stateChangeCallback = null;
|
this.stateChangeCallback = null;
|
||||||
this.pollingStates = {};
|
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 {
|
onConnectionChange(callback: (connected: boolean) => void): void {
|
||||||
this.connectionChangeCallback = callback;
|
this.connectionChangeCallback = callback;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@
|
|||||||
"demo_reset": "Reset",
|
"demo_reset": "Reset",
|
||||||
"demo_tour": "Tour",
|
"demo_tour": "Tour",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
|
"folders": "Folders",
|
||||||
"mail": "Mail",
|
"mail": "Mail",
|
||||||
"nav_label": "Navigation",
|
"nav_label": "Navigation",
|
||||||
"add_app": "Apps"
|
"add_app": "Apps"
|
||||||
|
|||||||
Reference in New Issue
Block a user