Files
SRCmail/stores/pro-tab-store.ts
T
2026-05-18 19:17:39 +02:00

305 lines
9.4 KiB
TypeScript

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import type { ComposerDraftData } from '@/components/email/email-composer';
export type ProTabKind =
| 'mail' | 'calendar' | 'contacts' | 'files' | 'settings'
| 'compose' | 'email';
export type ProComposerMode = 'compose' | 'reply' | 'replyAll' | 'forward';
/**
* Mirror of `EmailComposer.replyTo` — kept as a structural type here so the
* tab store doesn't take a runtime dependency on the composer module.
*/
export interface ProReplyContext {
from?: { email?: string; name?: string }[];
replyToAddresses?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
bcc?: { email?: string; name?: string }[];
subject?: string;
body?: string;
htmlBody?: string;
receivedAt?: string;
accountId?: string;
attachments?: Array<{
blobId: string; name?: string; type: string; size: number;
cid?: string; disposition?: string;
}>;
messageId?: string;
inReplyTo?: string[];
references?: string[];
quoteHeaderHtml?: string;
quoteHeaderText?: string;
quoteWrapInBlockquote?: boolean;
}
export interface ProComposeTabData {
/** Stable session id; used by the composer for draft autosave keying. */
sessionId: number;
mode: ProComposerMode;
replyTo?: ProReplyContext;
initialDraftText?: string;
initialData?: ComposerDraftData | null;
/** The id of the source email when replying/forwarding (for $answered/$forwarded). */
sourceEmailId?: string | null;
/** Tab title derived on open; updated as the composer subject changes. */
title: string;
}
export interface ProEmailTabData {
accountId: string;
emailId: string;
mailboxId: string | null;
title: string;
}
export interface ProTab {
id: string;
kind: ProTabKind;
/** i18n key under `sidebar.*` for built-in app tabs. Empty for compose/email. */
labelKey: string;
/** Dynamic title for compose/email tabs (overrides labelKey when present). */
title?: string;
closeable: boolean;
composeData?: ProComposeTabData;
emailData?: ProEmailTabData;
}
interface ProTabState {
tabs: ProTab[];
activeTabId: string;
loadedTabIds: string[];
openTab: (kind: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => string;
openComposeTab: (data: ProComposeTabData) => string;
openEmailTab: (data: ProEmailTabData) => string;
closeTab: (id: string) => void;
setActiveTab: (id: string) => void;
moveTab: (fromIdx: number, toIdx: number) => void;
/** Update the dynamic title of a tab (used by compose tabs as the subject changes). */
updateTabTitle: (id: string, title: string) => void;
/** Persist updated draft state for a compose tab — used by the composer's onSaveState. */
updateComposeDraft: (id: string, draft: ComposerDraftData) => void;
}
const TAB_BLUEPRINTS: Record<'mail' | 'calendar' | 'contacts' | 'files' | 'settings', { labelKey: string }> = {
mail: { labelKey: 'mail' },
calendar: { labelKey: 'calendar' },
contacts: { labelKey: 'contacts' },
files: { labelKey: 'files' },
settings: { labelKey: 'settings' },
};
const HOME_TAB: ProTab = {
id: 'home-mail',
kind: 'mail',
labelKey: TAB_BLUEPRINTS.mail.labelKey,
closeable: false,
};
function makeId(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `pro-tab-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export const useProTabStore = create<ProTabState>()(
persist(
(set, get) => ({
tabs: [HOME_TAB],
activeTabId: HOME_TAB.id,
loadedTabIds: [HOME_TAB.id],
openTab: (kind) => {
const state = get();
const existing = state.tabs.find((tab) => tab.kind === kind);
if (existing) {
if (state.activeTabId !== existing.id) {
set({
activeTabId: existing.id,
loadedTabIds: state.loadedTabIds.includes(existing.id)
? state.loadedTabIds
: [...state.loadedTabIds, existing.id],
});
}
return existing.id;
}
const blueprint = TAB_BLUEPRINTS[kind];
const newTab: ProTab = {
id: makeId(),
kind,
labelKey: blueprint.labelKey,
closeable: true,
};
set({
tabs: [...state.tabs, newTab],
activeTabId: newTab.id,
loadedTabIds: [...state.loadedTabIds, newTab.id],
});
return newTab.id;
},
openComposeTab: (data) => {
const state = get();
const newTab: ProTab = {
id: makeId(),
kind: 'compose',
labelKey: '',
title: data.title,
closeable: true,
composeData: data,
};
set({
tabs: [...state.tabs, newTab],
activeTabId: newTab.id,
loadedTabIds: [...state.loadedTabIds, newTab.id],
});
return newTab.id;
},
openEmailTab: (data) => {
const state = get();
// Focus an existing email tab for the same message instead of duplicating.
const existing = state.tabs.find(
(tab) => tab.kind === 'email'
&& tab.emailData?.emailId === data.emailId
&& tab.emailData?.accountId === data.accountId
);
if (existing) {
if (state.activeTabId !== existing.id) {
set({
activeTabId: existing.id,
loadedTabIds: state.loadedTabIds.includes(existing.id)
? state.loadedTabIds
: [...state.loadedTabIds, existing.id],
});
}
return existing.id;
}
const newTab: ProTab = {
id: makeId(),
kind: 'email',
labelKey: '',
title: data.title,
closeable: true,
emailData: data,
};
set({
tabs: [...state.tabs, newTab],
activeTabId: newTab.id,
loadedTabIds: [...state.loadedTabIds, newTab.id],
});
return newTab.id;
},
closeTab: (id) => {
const state = get();
const tab = state.tabs.find((t) => t.id === id);
if (!tab || !tab.closeable) return;
const idx = state.tabs.findIndex((t) => t.id === id);
const newTabs = state.tabs.filter((t) => t.id !== id);
const newLoaded = state.loadedTabIds.filter((tid) => tid !== id);
let newActive = state.activeTabId;
if (state.activeTabId === id) {
const neighbor = newTabs[idx] ?? newTabs[idx - 1] ?? newTabs[0];
newActive = neighbor?.id ?? HOME_TAB.id;
}
if (newTabs.length === 0) {
set({
tabs: [HOME_TAB],
activeTabId: HOME_TAB.id,
loadedTabIds: [HOME_TAB.id],
});
return;
}
set({
tabs: newTabs,
activeTabId: newActive,
loadedTabIds: newLoaded.includes(newActive) ? newLoaded : [...newLoaded, newActive],
});
},
setActiveTab: (id) => {
const state = get();
if (!state.tabs.some((t) => t.id === id)) return;
if (state.activeTabId === id) return;
set({
activeTabId: id,
loadedTabIds: state.loadedTabIds.includes(id)
? state.loadedTabIds
: [...state.loadedTabIds, id],
});
},
moveTab: (fromIdx, toIdx) => {
const state = get();
if (fromIdx === toIdx) return;
if (fromIdx < 0 || fromIdx >= state.tabs.length) return;
if (toIdx < 0 || toIdx >= state.tabs.length) return;
const tabs = [...state.tabs];
const [moved] = tabs.splice(fromIdx, 1);
tabs.splice(toIdx, 0, moved);
set({ tabs });
},
updateTabTitle: (id, title) => {
const state = get();
const tabs = state.tabs.map((tab) =>
tab.id === id ? { ...tab, title } : tab
);
set({ tabs });
},
updateComposeDraft: (id, draft) => {
const state = get();
const tabs = state.tabs.map((tab) => {
if (tab.id !== id || tab.kind !== 'compose' || !tab.composeData) return tab;
return {
...tab,
composeData: { ...tab.composeData, initialData: draft },
};
});
set({ tabs });
},
}),
{
name: 'pro-tabs',
version: 2,
// Don't persist transient compose drafts in tab metadata — the composer's
// own draft-store already handles that. Persisted email tabs are fine to
// restore (the tab body refetches the email by id).
partialize: (state) => ({
tabs: state.tabs.map((tab) =>
tab.kind === 'compose'
? { ...tab, composeData: undefined } // drop compose tabs on reload
: tab
).filter((tab) => tab.kind !== 'compose'),
activeTabId: state.activeTabId,
loadedTabIds: state.loadedTabIds,
}),
onRehydrateStorage: () => (state) => {
if (!state) return;
if (state.tabs.length === 0) {
state.tabs = [HOME_TAB];
state.activeTabId = HOME_TAB.id;
state.loadedTabIds = [HOME_TAB.id];
return;
}
if (!state.tabs.some((t) => t.id === state.activeTabId)) {
state.activeTabId = state.tabs[0].id;
}
if (!state.loadedTabIds.includes(state.activeTabId)) {
state.loadedTabIds = [...state.loadedTabIds, state.activeTabId];
}
},
},
),
);