feat: fix: enforce admin theme locks and repair theme ZIP bundles
This commit is contained in:
+15
-8
@@ -116,6 +116,7 @@ export default function Home() {
|
|||||||
markAsRead,
|
markAsRead,
|
||||||
toggleStar,
|
toggleStar,
|
||||||
moveToMailbox,
|
moveToMailbox,
|
||||||
|
moveThreadToMailbox,
|
||||||
searchEmails,
|
searchEmails,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
@@ -604,8 +605,8 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleArchive = async () => {
|
const handleArchive = async (emailToArchive: Email | null = selectedEmail) => {
|
||||||
if (!client || !selectedEmail) return;
|
if (!client || !emailToArchive) return;
|
||||||
|
|
||||||
// Find archive mailbox
|
// Find archive mailbox
|
||||||
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
const archiveMailbox = mailboxes.find(m => m.role === "archive" || m.name.toLowerCase() === "archive");
|
||||||
@@ -615,10 +616,10 @@ export default function Home() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (archiveMode === 'single') {
|
if (archiveMode === 'single') {
|
||||||
await moveToMailbox(client, selectedEmail.id, archiveMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, archiveMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// Determine year/month from the email's received date
|
// Determine year/month from the email's received date
|
||||||
const emailDate = new Date(selectedEmail.receivedAt);
|
const emailDate = new Date(emailToArchive.receivedAt);
|
||||||
const year = emailDate.getFullYear().toString();
|
const year = emailDate.getFullYear().toString();
|
||||||
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
const month = (emailDate.getMonth() + 1).toString().padStart(2, '0');
|
||||||
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
const archiveId = archiveMailbox.originalId || archiveMailbox.id;
|
||||||
@@ -633,7 +634,7 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (archiveMode === 'year') {
|
if (archiveMode === 'year') {
|
||||||
await moveToMailbox(client, selectedEmail.id, yearMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, yearMailbox.id);
|
||||||
} else {
|
} else {
|
||||||
// archiveMode === 'month' — find or create month subfolder under year
|
// archiveMode === 'month' — find or create month subfolder under year
|
||||||
const yearId = yearMailbox.originalId || yearMailbox.id;
|
const yearId = yearMailbox.originalId || yearMailbox.id;
|
||||||
@@ -644,9 +645,16 @@ export default function Home() {
|
|||||||
monthMailbox = await client.createMailbox(month, yearId);
|
monthMailbox = await client.createMailbox(month, yearId);
|
||||||
await fetchMailboxes(client);
|
await fetchMailboxes(client);
|
||||||
}
|
}
|
||||||
await moveToMailbox(client, selectedEmail.id, monthMailbox.id);
|
await moveThreadToMailbox(client, emailToArchive.id, monthMailbox.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (conversationThread?.threadId === emailToArchive.threadId) {
|
||||||
|
setConversationThread(null);
|
||||||
|
setConversationEmails([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
void fetchMailboxes(client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to archive email:", error);
|
console.error("Failed to archive email:", error);
|
||||||
}
|
}
|
||||||
@@ -1453,8 +1461,7 @@ export default function Home() {
|
|||||||
await handleDelete();
|
await handleDelete();
|
||||||
}}
|
}}
|
||||||
onArchive={async (email) => {
|
onArchive={async (email) => {
|
||||||
selectEmail(email);
|
await handleArchive(email);
|
||||||
await handleArchive();
|
|
||||||
}}
|
}}
|
||||||
onSetColorTag={(emailId, color) => {
|
onSetColorTag={(emailId, color) => {
|
||||||
handleSetColorTag(emailId, color);
|
handleSetColorTag(emailId, color);
|
||||||
|
|||||||
@@ -14,24 +14,31 @@ export function ThemesSettings() {
|
|||||||
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
const { installedThemes, activeThemeId, installTheme, uninstallTheme, activateTheme } = useThemeStore();
|
||||||
const [isUploading, setIsUploading] = useState(false);
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const { isFeatureEnabled, isThemeDisabled, getThemePolicy, isThemeForceEnabled } = usePolicyStore();
|
const { isFeatureEnabled, isThemeDisabled, getThemePolicy, getForcedThemeId, isThemeForceEnabled } = usePolicyStore();
|
||||||
const canUpload = isFeatureEnabled('userThemesEnabled');
|
const canUpload = isFeatureEnabled('userThemesEnabled');
|
||||||
const themePolicy = getThemePolicy();
|
const themePolicy = getThemePolicy();
|
||||||
|
const forcedThemeId = getForcedThemeId(installedThemes.map((theme) => theme.id));
|
||||||
|
|
||||||
// Filter out themes disabled by admin policy
|
// Filter out themes disabled by admin policy
|
||||||
const visibleThemes = installedThemes.filter(
|
const visibleThemes = installedThemes.filter(
|
||||||
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
|
theme => !isThemeDisabled(theme.id, !!theme.builtIn)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (forcedThemeId && activeThemeId !== forcedThemeId) {
|
||||||
|
activateTheme(forcedThemeId);
|
||||||
|
}
|
||||||
|
}, [activeThemeId, activateTheme, forcedThemeId]);
|
||||||
|
|
||||||
// If the active theme was disabled by admin, fall back to default
|
// If the active theme was disabled by admin, fall back to default
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeThemeId) {
|
if (activeThemeId) {
|
||||||
const activeTheme = installedThemes.find(t => t.id === activeThemeId);
|
const activeTheme = installedThemes.find(t => t.id === activeThemeId);
|
||||||
if (activeTheme && isThemeDisabled(activeThemeId, !!activeTheme.builtIn)) {
|
if (activeTheme && isThemeDisabled(activeThemeId, !!activeTheme.builtIn)) {
|
||||||
activateTheme(null);
|
activateTheme(forcedThemeId ?? null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [activeThemeId, installedThemes, isThemeDisabled, activateTheme]);
|
}, [activeThemeId, activateTheme, forcedThemeId, installedThemes, isThemeDisabled]);
|
||||||
|
|
||||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -58,12 +65,23 @@ export function ThemesSettings() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleActivate = (id: string | null) => {
|
const handleActivate = (id: string | null) => {
|
||||||
|
if (forcedThemeId && id !== forcedThemeId) {
|
||||||
|
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||||
|
toast.info(`Theme "${forcedTheme?.name ?? 'Admin theme'}" is forced by admin and cannot be changed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
activateTheme(id);
|
activateTheme(id);
|
||||||
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
toast.success(id ? 'Theme activated' : 'Default theme restored');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUninstall = (theme: InstalledTheme) => {
|
const handleUninstall = (theme: InstalledTheme) => {
|
||||||
if (theme.builtIn) return;
|
if (theme.builtIn) return;
|
||||||
|
if (theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id)) {
|
||||||
|
toast.info(`Theme "${theme.name}" is forced by admin and cannot be removed`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
uninstallTheme(theme.id);
|
uninstallTheme(theme.id);
|
||||||
toast.success('Theme removed');
|
toast.success('Theme removed');
|
||||||
};
|
};
|
||||||
@@ -71,6 +89,12 @@ export function ThemesSettings() {
|
|||||||
return (
|
return (
|
||||||
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets." experimental experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates. Built-in presets are stable, but uploaded themes may require updates after application upgrades.">
|
<SettingsSection title="Themes" description="Customize the appearance with color themes. Upload .zip theme files or activate built-in presets." experimental experimentalDescription="Themes is an experimental feature. Custom themes may not cover all UI elements, and theme formats could change in future updates. Built-in presets are stable, but uploaded themes may require updates after application upgrades.">
|
||||||
|
|
||||||
|
{forcedThemeId && (
|
||||||
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-800 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-300">
|
||||||
|
Theme selection is locked by an administrator.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Theme Grid */}
|
{/* Theme Grid */}
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
{/* Default theme card */}
|
{/* Default theme card */}
|
||||||
@@ -80,11 +104,14 @@ export function ThemesSettings() {
|
|||||||
isActive={activeThemeId === null}
|
isActive={activeThemeId === null}
|
||||||
isBuiltIn
|
isBuiltIn
|
||||||
isDefault={!themePolicy.defaultThemeId}
|
isDefault={!themePolicy.defaultThemeId}
|
||||||
|
disabled={Boolean(forcedThemeId)}
|
||||||
onActivate={() => handleActivate(null)}
|
onActivate={() => handleActivate(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Installed themes */}
|
{/* Installed themes */}
|
||||||
{visibleThemes.map(theme => (
|
{visibleThemes.map(theme => {
|
||||||
|
const isForceEnabled = theme.id === forcedThemeId || theme.forceEnabled || isThemeForceEnabled(theme.id);
|
||||||
|
return (
|
||||||
<ThemeCard
|
<ThemeCard
|
||||||
key={theme.id}
|
key={theme.id}
|
||||||
name={theme.name}
|
name={theme.name}
|
||||||
@@ -93,12 +120,14 @@ export function ThemesSettings() {
|
|||||||
isActive={activeThemeId === theme.id}
|
isActive={activeThemeId === theme.id}
|
||||||
isBuiltIn={theme.builtIn}
|
isBuiltIn={theme.builtIn}
|
||||||
isDefault={themePolicy.defaultThemeId === theme.id}
|
isDefault={themePolicy.defaultThemeId === theme.id}
|
||||||
isForceEnabled={isThemeForceEnabled(theme.id)}
|
isForceEnabled={isForceEnabled}
|
||||||
|
disabled={Boolean(forcedThemeId) && !isForceEnabled}
|
||||||
variants={theme.variants}
|
variants={theme.variants}
|
||||||
onActivate={() => handleActivate(theme.id)}
|
onActivate={() => handleActivate(theme.id)}
|
||||||
onRemove={!theme.builtIn ? () => handleUninstall(theme) : undefined}
|
onRemove={!theme.builtIn && !isForceEnabled ? () => handleUninstall(theme) : undefined}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload */}
|
{/* Upload */}
|
||||||
@@ -137,20 +166,23 @@ interface ThemeCardProps {
|
|||||||
isBuiltIn: boolean;
|
isBuiltIn: boolean;
|
||||||
isDefault?: boolean;
|
isDefault?: boolean;
|
||||||
isForceEnabled?: boolean;
|
isForceEnabled?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
variants?: ('light' | 'dark')[];
|
variants?: ('light' | 'dark')[];
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
onRemove?: () => void;
|
onRemove?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
function ThemeCard({ name, author, preview, isActive, isDefault, isForceEnabled, disabled, variants, onActivate, onRemove }: ThemeCardProps) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onActivate}
|
onClick={onActivate}
|
||||||
|
disabled={disabled}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full',
|
'relative flex flex-col items-center p-3 rounded-xl border-2 transition-all text-left w-full disabled:cursor-not-allowed disabled:opacity-60',
|
||||||
isActive
|
isActive
|
||||||
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
? 'border-primary bg-primary/5 ring-1 ring-primary/20'
|
||||||
: 'border-border hover:border-primary/40 bg-card'
|
: 'border-border hover:border-primary/40 bg-card',
|
||||||
|
disabled && !isActive && 'hover:border-border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* Preview / Placeholder */}
|
{/* Preview / Placeholder */}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export interface IJMAPClient {
|
|||||||
deleteEmail(emailId: string): Promise<void>;
|
deleteEmail(emailId: string): Promise<void>;
|
||||||
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string): Promise<void>;
|
||||||
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
batchDeleteEmails(emailIds: string[]): Promise<void>;
|
||||||
batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void>;
|
batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
moveEmail(emailId: string, toMailboxId: string, accountId?: string): Promise<void>;
|
||||||
emptyMailbox(mailboxId: string): Promise<number>;
|
emptyMailbox(mailboxId: string): Promise<number>;
|
||||||
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
markAsSpam(emailId: string, accountId?: string): Promise<void>;
|
||||||
|
|||||||
+2
-2
@@ -980,12 +980,12 @@ export class JMAPClient implements IJMAPClient {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async batchMoveEmails(emailIds: string[], toMailboxId: string): Promise<void> {
|
async batchMoveEmails(emailIds: string[], toMailboxId: string, accountId?: string): Promise<void> {
|
||||||
if (emailIds.length === 0) return;
|
if (emailIds.length === 0) return;
|
||||||
|
|
||||||
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }]));
|
const updates = Object.fromEntries(emailIds.map(id => [id, { mailboxIds: { [toMailboxId]: true } }]));
|
||||||
await this.request([
|
await this.request([
|
||||||
["Email/set", { accountId: this.accountId, update: updates }, "0"],
|
["Email/set", { accountId: accountId || this.accountId, update: updates }, "0"],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export interface InstalledTheme {
|
|||||||
variants: ThemeVariant[];
|
variants: ThemeVariant[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
builtIn: boolean;
|
builtIn: boolean;
|
||||||
|
managed?: boolean;
|
||||||
|
forceEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstalledPlugin {
|
export interface InstalledPlugin {
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { useEmailStore } from '../email-store';
|
||||||
|
import type { Email, Mailbox } from '@/lib/jmap/types';
|
||||||
|
import type { IJMAPClient } from '@/lib/jmap/client-interface';
|
||||||
|
|
||||||
|
function makeMailbox(overrides: Partial<Mailbox> = {}): Mailbox {
|
||||||
|
return {
|
||||||
|
id: overrides.id ?? 'inbox',
|
||||||
|
name: overrides.name ?? 'Inbox',
|
||||||
|
sortOrder: 0,
|
||||||
|
totalEmails: 0,
|
||||||
|
unreadEmails: 0,
|
||||||
|
totalThreads: 0,
|
||||||
|
unreadThreads: 0,
|
||||||
|
myRights: {
|
||||||
|
mayReadItems: true,
|
||||||
|
mayAddItems: true,
|
||||||
|
mayRemoveItems: true,
|
||||||
|
maySetSeen: true,
|
||||||
|
maySetKeywords: true,
|
||||||
|
mayCreateChild: true,
|
||||||
|
mayRename: true,
|
||||||
|
mayDelete: true,
|
||||||
|
maySubmit: true,
|
||||||
|
},
|
||||||
|
isSubscribed: true,
|
||||||
|
isShared: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeEmail(id: string, threadId: string, mailboxId = 'inbox'): Email {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
threadId,
|
||||||
|
mailboxIds: { [mailboxId]: true },
|
||||||
|
keywords: {},
|
||||||
|
size: 100,
|
||||||
|
receivedAt: new Date().toISOString(),
|
||||||
|
from: [{ name: 'Test', email: 'test@example.com' }],
|
||||||
|
to: [{ name: 'User', email: 'user@example.com' }],
|
||||||
|
subject: `Email ${id}`,
|
||||||
|
preview: 'preview',
|
||||||
|
hasAttachment: false,
|
||||||
|
textBody: [],
|
||||||
|
htmlBody: [],
|
||||||
|
bodyValues: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('email-store archive thread behavior', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
const inbox = makeMailbox({ id: 'shared-inbox', role: 'inbox', isShared: true, accountId: 'shared-account', originalId: 'server-inbox' });
|
||||||
|
const archive = makeMailbox({ id: 'archive-local', role: 'archive', accountId: 'shared-account', originalId: 'server-archive' });
|
||||||
|
const threadEmailA = makeEmail('email-1', 'thread-1', 'shared-inbox');
|
||||||
|
const threadEmailB = makeEmail('email-2', 'thread-1', 'shared-inbox');
|
||||||
|
const otherEmail = makeEmail('email-3', 'thread-2', 'shared-inbox');
|
||||||
|
|
||||||
|
useEmailStore.setState({
|
||||||
|
emails: [threadEmailA, threadEmailB, otherEmail],
|
||||||
|
mailboxes: [inbox, archive],
|
||||||
|
selectedMailbox: 'shared-inbox',
|
||||||
|
selectedEmail: threadEmailA,
|
||||||
|
selectedEmailIds: new Set(['email-1', 'email-2']),
|
||||||
|
expandedThreadIds: new Set(['thread-1']),
|
||||||
|
threadEmailsCache: new Map([['thread-1', [threadEmailA, threadEmailB]]]),
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('archives the full thread and removes cached thread state', async () => {
|
||||||
|
const client = {
|
||||||
|
getThread: vi.fn().mockResolvedValue({ id: 'thread-1', emailIds: ['email-1', 'email-2'] }),
|
||||||
|
batchMoveEmails: vi.fn().mockResolvedValue(undefined),
|
||||||
|
} as unknown as IJMAPClient;
|
||||||
|
|
||||||
|
await useEmailStore.getState().moveThreadToMailbox(client, 'email-1', 'archive-local');
|
||||||
|
|
||||||
|
expect(client.getThread).toHaveBeenCalledWith('thread-1', 'shared-account');
|
||||||
|
expect(client.batchMoveEmails).toHaveBeenCalledWith(['email-1', 'email-2'], 'server-archive', 'shared-account');
|
||||||
|
|
||||||
|
const state = useEmailStore.getState();
|
||||||
|
expect(state.emails.map(email => email.id)).toEqual(['email-3']);
|
||||||
|
expect(state.selectedEmail?.id).toBe('email-3');
|
||||||
|
expect(Array.from(state.selectedEmailIds)).toEqual([]);
|
||||||
|
expect(state.expandedThreadIds.has('thread-1')).toBe(false);
|
||||||
|
expect(state.threadEmailsCache.has('thread-1')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
+79
-6
@@ -67,6 +67,7 @@ interface EmailStore {
|
|||||||
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
deleteEmail: (client: IJMAPClient, emailId: string, forceDelete?: boolean) => Promise<void>;
|
||||||
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
markAsRead: (client: IJMAPClient, emailId: string, read: boolean) => Promise<void>;
|
||||||
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
moveToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||||
|
moveThreadToMailbox: (client: IJMAPClient, emailId: string, mailboxId: string) => Promise<void>;
|
||||||
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
||||||
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
||||||
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
setSearchFilters: (filters: Partial<SearchFilters>) => void;
|
||||||
@@ -111,16 +112,35 @@ interface EmailStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Helper: compute the next email to select when removing one from the list
|
// Helper: compute the next email to select when removing one from the list
|
||||||
function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailId: string): Email | null {
|
function getNextSelectedEmailAfterRemoval(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailIds: Set<string>): Email | null {
|
||||||
if (state.selectedEmail?.id !== removedEmailId) return state.selectedEmail;
|
if (!state.selectedEmail || !removedEmailIds.has(state.selectedEmail.id)) {
|
||||||
const idx = state.emails.findIndex(e => e.id === removedEmailId);
|
return state.selectedEmail;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = state.emails.findIndex(e => e.id === state.selectedEmail?.id);
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
// Prefer next email, fall back to previous
|
|
||||||
if (idx < state.emails.length - 1) return state.emails[idx + 1];
|
for (let nextIndex = idx + 1; nextIndex < state.emails.length; nextIndex++) {
|
||||||
if (idx > 0) return state.emails[idx - 1];
|
const candidate = state.emails[nextIndex];
|
||||||
|
if (!removedEmailIds.has(candidate.id)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let prevIndex = idx - 1; prevIndex >= 0; prevIndex--) {
|
||||||
|
const candidate = state.emails[prevIndex];
|
||||||
|
if (!removedEmailIds.has(candidate.id)) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getNextSelectedEmail(state: { emails: Email[]; selectedEmail: Email | null }, removedEmailId: string): Email | null {
|
||||||
|
return getNextSelectedEmailAfterRemoval(state, new Set([removedEmailId]));
|
||||||
|
}
|
||||||
|
|
||||||
export const useEmailStore = create<EmailStore>((set, get) => ({
|
export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||||
emails: [],
|
emails: [],
|
||||||
mailboxes: [],
|
mailboxes: [],
|
||||||
@@ -703,6 +723,59 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
moveThreadToMailbox: async (client, emailId, destinationMailboxId) => {
|
||||||
|
try {
|
||||||
|
const state = get();
|
||||||
|
const email = state.emails.find(e => e.id === emailId)
|
||||||
|
?? (state.selectedEmail?.id === emailId ? state.selectedEmail : null);
|
||||||
|
|
||||||
|
if (!email?.threadId) {
|
||||||
|
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMailbox = state.mailboxes.find(mb => mb.id === state.selectedMailbox);
|
||||||
|
const accountId = currentMailbox?.isShared ? currentMailbox.accountId : undefined;
|
||||||
|
const destMailbox = state.mailboxes.find(mb => mb.id === destinationMailboxId);
|
||||||
|
const jmapDestId = destMailbox?.originalId || destinationMailboxId;
|
||||||
|
|
||||||
|
const thread = await client.getThread(email.threadId, accountId);
|
||||||
|
const threadEmailIds = thread?.emailIds?.length ? thread.emailIds : [emailId];
|
||||||
|
|
||||||
|
if (threadEmailIds.length <= 1) {
|
||||||
|
await get().moveToMailbox(client, emailId, destinationMailboxId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.batchMoveEmails(threadEmailIds, jmapDestId, accountId);
|
||||||
|
|
||||||
|
const removedEmailIds = new Set(threadEmailIds);
|
||||||
|
set((currentState) => {
|
||||||
|
const nextSelectedEmail = getNextSelectedEmailAfterRemoval(currentState, removedEmailIds);
|
||||||
|
const nextSelectedEmailIds = new Set(
|
||||||
|
Array.from(currentState.selectedEmailIds).filter(id => !removedEmailIds.has(id))
|
||||||
|
);
|
||||||
|
const nextExpandedThreadIds = new Set(currentState.expandedThreadIds);
|
||||||
|
nextExpandedThreadIds.delete(email.threadId);
|
||||||
|
const nextThreadEmailsCache = new Map(currentState.threadEmailsCache);
|
||||||
|
nextThreadEmailsCache.delete(email.threadId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
emails: currentState.emails.filter(currentEmail => !removedEmailIds.has(currentEmail.id)),
|
||||||
|
selectedEmail: nextSelectedEmail,
|
||||||
|
selectedEmailIds: nextSelectedEmailIds,
|
||||||
|
expandedThreadIds: nextExpandedThreadIds,
|
||||||
|
threadEmailsCache: nextThreadEmailsCache,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
set({
|
||||||
|
error: error instanceof Error ? error.message : "Failed to move email thread"
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
searchEmails: async (client, query) => {
|
searchEmails: async (client, query) => {
|
||||||
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
set({ isLoading: true, error: null, searchQuery: query, emails: [], hasMoreEmails: false, totalEmails: 0 }); // Clear emails for loading state
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface PolicyState {
|
|||||||
getRestriction: (key: string) => SettingRestriction | undefined;
|
getRestriction: (key: string) => SettingRestriction | undefined;
|
||||||
getEffectiveDefault: (key: string) => unknown;
|
getEffectiveDefault: (key: string) => unknown;
|
||||||
getThemePolicy: () => ThemePolicy;
|
getThemePolicy: () => ThemePolicy;
|
||||||
|
getForcedThemeId: (availableThemeIds?: string[]) => string | null;
|
||||||
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
isThemeDisabled: (themeId: string, isBuiltIn: boolean) => boolean;
|
||||||
isPluginForceEnabled: (pluginId: string) => boolean;
|
isPluginForceEnabled: (pluginId: string) => boolean;
|
||||||
isThemeForceEnabled: (themeId: string) => boolean;
|
isThemeForceEnabled: (themeId: string) => boolean;
|
||||||
@@ -61,6 +62,16 @@ export const usePolicyStore = create<PolicyState>()((set, get) => ({
|
|||||||
return get().policy.themePolicy || { ...DEFAULT_THEME_POLICY };
|
return get().policy.themePolicy || { ...DEFAULT_THEME_POLICY };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getForcedThemeId: (availableThemeIds) => {
|
||||||
|
const forceEnabledThemes = get().policy.forceEnabledThemes || [];
|
||||||
|
if (!availableThemeIds || availableThemeIds.length === 0) {
|
||||||
|
return forceEnabledThemes[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = new Set(availableThemeIds);
|
||||||
|
return forceEnabledThemes.find((themeId) => available.has(themeId)) || null;
|
||||||
|
},
|
||||||
|
|
||||||
isThemeDisabled: (themeId, isBuiltIn) => {
|
isThemeDisabled: (themeId, isBuiltIn) => {
|
||||||
const tp = get().policy.themePolicy || DEFAULT_THEME_POLICY;
|
const tp = get().policy.themePolicy || DEFAULT_THEME_POLICY;
|
||||||
if (isBuiltIn) {
|
if (isBuiltIn) {
|
||||||
|
|||||||
+75
-16
@@ -9,6 +9,18 @@ import { usePolicyStore } from '@/stores/policy-store';
|
|||||||
|
|
||||||
type Theme = 'light' | 'dark' | 'system';
|
type Theme = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
function getForcedThemeId(installedThemes: InstalledTheme[]): string | null {
|
||||||
|
const policyForcedThemeId = usePolicyStore
|
||||||
|
.getState()
|
||||||
|
.getForcedThemeId(installedThemes.map((theme) => theme.id));
|
||||||
|
|
||||||
|
if (policyForcedThemeId) {
|
||||||
|
return policyForcedThemeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return installedThemes.find((theme) => theme.forceEnabled)?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
interface ThemeState {
|
interface ThemeState {
|
||||||
theme: Theme;
|
theme: Theme;
|
||||||
resolvedTheme: 'light' | 'dark';
|
resolvedTheme: 'light' | 'dark';
|
||||||
@@ -90,18 +102,21 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
applyTheme(resolvedTheme);
|
applyTheme(resolvedTheme);
|
||||||
set({ resolvedTheme, hydrated: true });
|
set({ resolvedTheme, hydrated: true });
|
||||||
|
|
||||||
// Determine effective theme: user choice > policy default > none
|
// Determine effective theme: forced theme > user choice > policy default > none
|
||||||
let effectiveThemeId = activeThemeId;
|
const forcedThemeId = getForcedThemeId(installedThemes);
|
||||||
|
let effectiveThemeId = forcedThemeId ?? activeThemeId;
|
||||||
if (!effectiveThemeId) {
|
if (!effectiveThemeId) {
|
||||||
const policyState = usePolicyStore.getState();
|
const policyState = usePolicyStore.getState();
|
||||||
const tp = policyState.policy.themePolicy;
|
const tp = policyState.policy.themePolicy;
|
||||||
if (tp?.defaultThemeId) {
|
if (tp?.defaultThemeId) {
|
||||||
effectiveThemeId = tp.defaultThemeId;
|
effectiveThemeId = tp.defaultThemeId;
|
||||||
// Persist so we don't re-check every time
|
|
||||||
set({ activeThemeId: effectiveThemeId });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (effectiveThemeId !== activeThemeId) {
|
||||||
|
set({ activeThemeId: effectiveThemeId });
|
||||||
|
}
|
||||||
|
|
||||||
// Apply active custom theme on boot
|
// Apply active custom theme on boot
|
||||||
if (effectiveThemeId) {
|
if (effectiveThemeId) {
|
||||||
const t = installedThemes.find(t => t.id === effectiveThemeId);
|
const t = installedThemes.find(t => t.id === effectiveThemeId);
|
||||||
@@ -215,6 +230,8 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
const { installedThemes, activeThemeId } = get();
|
const { installedThemes, activeThemeId } = get();
|
||||||
const theme = installedThemes.find(t => t.id === id);
|
const theme = installedThemes.find(t => t.id === id);
|
||||||
if (!theme || theme.builtIn) return;
|
if (!theme || theme.builtIn) return;
|
||||||
|
const forceEnabledByPolicy = usePolicyStore.getState().isThemeForceEnabled(id);
|
||||||
|
if (theme.forceEnabled || forceEnabledByPolicy) return;
|
||||||
|
|
||||||
// Deactivate if active
|
// Deactivate if active
|
||||||
if (activeThemeId === id) {
|
if (activeThemeId === id) {
|
||||||
@@ -232,16 +249,44 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
activateTheme: (id: string | null) => {
|
activateTheme: (id: string | null) => {
|
||||||
|
const { installedThemes, resolvedTheme } = get();
|
||||||
|
const forcedThemeId = getForcedThemeId(installedThemes);
|
||||||
|
|
||||||
|
if (forcedThemeId && id !== forcedThemeId) {
|
||||||
|
const forcedTheme = installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||||
|
if (forcedTheme) {
|
||||||
|
applyCustomThemeCSS(forcedTheme, resolvedTheme);
|
||||||
|
set({ activeThemeId: forcedThemeId });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (id === null) {
|
if (id === null) {
|
||||||
removeThemeCSS();
|
removeThemeCSS();
|
||||||
set({ activeThemeId: null });
|
set({ activeThemeId: null });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { installedThemes, resolvedTheme } = get();
|
|
||||||
const theme = installedThemes.find(t => t.id === id);
|
const theme = installedThemes.find(t => t.id === id);
|
||||||
if (!theme) return;
|
if (!theme) return;
|
||||||
|
|
||||||
|
if (!theme.css) {
|
||||||
|
pluginStorage.getThemeCSS(id).then((css) => {
|
||||||
|
if (!css) return;
|
||||||
|
|
||||||
|
const hydratedTheme = { ...theme, css };
|
||||||
|
applyCustomThemeCSS(hydratedTheme, get().resolvedTheme);
|
||||||
|
set((state) => ({
|
||||||
|
activeThemeId: id,
|
||||||
|
installedThemes: state.installedThemes.map((item) =>
|
||||||
|
item.id === id ? { ...item, css } : item
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
set({ activeThemeId: id });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
applyCustomThemeCSS(theme, resolvedTheme);
|
applyCustomThemeCSS(theme, resolvedTheme);
|
||||||
set({ activeThemeId: id });
|
set({ activeThemeId: id });
|
||||||
},
|
},
|
||||||
@@ -281,6 +326,8 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
variants: st.variants as ThemeVariant[],
|
variants: st.variants as ThemeVariant[],
|
||||||
enabled: true,
|
enabled: true,
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
|
managed: true,
|
||||||
|
forceEnabled: st.forceEnabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
await pluginStorage.saveThemeCSS(st.id, sanitized.css);
|
await pluginStorage.saveThemeCSS(st.id, sanitized.css);
|
||||||
@@ -293,18 +340,17 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
// If this is force-enabled and no theme is active, activate it
|
} else if (!local.builtIn) {
|
||||||
if (st.forceEnabled && !get().activeThemeId) {
|
let css = local.css;
|
||||||
applyCustomThemeCSS(theme, get().resolvedTheme);
|
|
||||||
set({ activeThemeId: st.id });
|
|
||||||
}
|
|
||||||
} else if (!local.builtIn && local.version !== st.version) {
|
|
||||||
// Version changed — re-download CSS
|
|
||||||
const css = await downloadThemeCSS(st.id);
|
|
||||||
if (!css) continue;
|
|
||||||
|
|
||||||
const sanitized = sanitizeThemeCSS(css);
|
if (local.version !== st.version || !css) {
|
||||||
|
const downloadedCss = await downloadThemeCSS(st.id);
|
||||||
|
if (!downloadedCss) continue;
|
||||||
|
|
||||||
|
const sanitized = sanitizeThemeCSS(downloadedCss);
|
||||||
|
css = sanitized.css;
|
||||||
await pluginStorage.saveThemeCSS(st.id, sanitized.css);
|
await pluginStorage.saveThemeCSS(st.id, sanitized.css);
|
||||||
|
}
|
||||||
|
|
||||||
const updatedTheme = {
|
const updatedTheme = {
|
||||||
...local,
|
...local,
|
||||||
@@ -312,8 +358,10 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
version: st.version,
|
version: st.version,
|
||||||
author: st.author,
|
author: st.author,
|
||||||
description: st.description || '',
|
description: st.description || '',
|
||||||
css: sanitized.css,
|
css,
|
||||||
variants: st.variants as ThemeVariant[],
|
variants: st.variants as ThemeVariant[],
|
||||||
|
managed: true,
|
||||||
|
forceEnabled: st.forceEnabled,
|
||||||
};
|
};
|
||||||
|
|
||||||
set(state => ({
|
set(state => ({
|
||||||
@@ -332,6 +380,15 @@ export const useThemeStore = create<ThemeState>()(
|
|||||||
set(state => ({
|
set(state => ({
|
||||||
installedThemes: dedupeInstalledThemes(state.installedThemes),
|
installedThemes: dedupeInstalledThemes(state.installedThemes),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const forcedThemeId = getForcedThemeId(get().installedThemes);
|
||||||
|
if (forcedThemeId && get().activeThemeId !== forcedThemeId) {
|
||||||
|
const forcedTheme = get().installedThemes.find((theme) => theme.id === forcedThemeId);
|
||||||
|
if (forcedTheme) {
|
||||||
|
applyCustomThemeCSS(forcedTheme, get().resolvedTheme);
|
||||||
|
set({ activeThemeId: forcedThemeId });
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
console.warn('[theme-store] Server theme sync failed');
|
console.warn('[theme-store] Server theme sync failed');
|
||||||
}
|
}
|
||||||
@@ -413,6 +470,8 @@ function dedupeInstalledThemes(themes: InstalledTheme[]): InstalledTheme[] {
|
|||||||
...theme,
|
...theme,
|
||||||
builtIn: existing.builtIn || theme.builtIn,
|
builtIn: existing.builtIn || theme.builtIn,
|
||||||
enabled: existing.enabled || theme.enabled,
|
enabled: existing.enabled || theme.enabled,
|
||||||
|
managed: existing.managed || theme.managed,
|
||||||
|
forceEnabled: theme.forceEnabled ?? existing.forceEnabled,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user