Merge branch 'main' into feature/scheduled-send
# Conflicts: # app/(main)/[locale]/page.tsx # components/email/email-list.tsx # components/email/email-viewer.tsx
This commit is contained in:
@@ -11,7 +11,7 @@ import { useAccountStore } from "@/stores/account-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react";
|
||||
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
@@ -722,7 +722,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-12 pb-4 text-center">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-6">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-20 max-h-20 object-contain"
|
||||
/>
|
||||
@@ -872,7 +872,7 @@ export default function LoginPage() {
|
||||
<div className="px-8 pt-10 pb-6 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 mb-5">
|
||||
<img
|
||||
src={resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl}
|
||||
src={withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl)}
|
||||
alt={appName}
|
||||
className="max-w-16 max-h-16 object-contain"
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/li
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import type { UnifiedAccountClient } from "@/lib/unified-mailbox";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useEmailStore, buildUnifiedAccountClients } from "@/stores/email-store";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
@@ -342,47 +342,22 @@ export default function Home() {
|
||||
const activeEmails = isScheduledView ? scheduledEmails : emails;
|
||||
const activeHasMore = isScheduledView ? scheduledHasMore : hasMoreEmails;
|
||||
const activeIsLoading = isScheduledView ? isLoadingScheduled : isLoading;
|
||||
const includeGroupInUnified = useSettingsStore((s) => s.includeGroupInUnified);
|
||||
const accounts = useAccountStore((s) => s.accounts);
|
||||
const connectedAccountsSignature = useMemo(
|
||||
() => accounts.filter((a) => a.isConnected).map((a) => a.id).sort().join(","),
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const buildUnifiedAccounts = useCallback((): UnifiedAccountClient[] => {
|
||||
const connected = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||
const clients = useAuthStore.getState().getAllConnectedClients();
|
||||
const result: UnifiedAccountClient[] = [];
|
||||
for (const account of connected) {
|
||||
const accountClient = clients.get(account.id);
|
||||
if (!accountClient) continue;
|
||||
result.push({
|
||||
accountId: account.id,
|
||||
accountLabel: account.label || account.email,
|
||||
client: accountClient,
|
||||
mailboxes: [],
|
||||
});
|
||||
}
|
||||
return result;
|
||||
// Builds the populated UnifiedAccountClient[] used by the unified-view
|
||||
// effects and one-shot actions in this page. Reads the includeGroup
|
||||
// setting at call time so the latest toggle value is always honored.
|
||||
const buildPopulatedUnifiedAccounts = useCallback(async (): Promise<UnifiedAccountClient[]> => {
|
||||
return buildUnifiedAccountClients({
|
||||
includeGroup: useSettingsStore.getState().includeGroupInUnified,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const populateUnifiedAccountMailboxes = useCallback(
|
||||
async (list: UnifiedAccountClient[]): Promise<UnifiedAccountClient[]> => {
|
||||
const populated = await Promise.all(
|
||||
list.map(async (entry) => {
|
||||
try {
|
||||
const mailboxes = await entry.client.getMailboxes();
|
||||
return { ...entry, mailboxes };
|
||||
} catch (err) {
|
||||
debug.error('Failed to load mailboxes for unified account', entry.accountId, err);
|
||||
return entry;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return populated;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const getMailtoProtocolAccounts = useCallback(() => {
|
||||
const connectedClients = useAuthStore.getState().getAllConnectedClients();
|
||||
return useAccountStore.getState().accounts.filter((account) =>
|
||||
@@ -1004,12 +979,12 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
if (!enableUnifiedMailbox && !isEmbedded) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
const built = buildUnifiedAccounts();
|
||||
if (built.length < 2) return;
|
||||
populateUnifiedAccountMailboxes(built).then((populated) => {
|
||||
refreshUnifiedCounts(populated);
|
||||
buildPopulatedUnifiedAccounts().then((built) => {
|
||||
const hasGroupEntry = built.some((b) => b.isShared);
|
||||
if (built.length < 2 && !hasGroupEntry && !isEmbedded) return;
|
||||
refreshUnifiedCounts(built);
|
||||
});
|
||||
}, [enableUnifiedMailbox, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildUnifiedAccounts, populateUnifiedAccountMailboxes, refreshUnifiedCounts]);
|
||||
}, [enableUnifiedMailbox, includeGroupInUnified, isEmbedded, isAuthenticated, client, mailboxes, connectedAccountsSignature, buildPopulatedUnifiedAccounts, refreshUnifiedCounts]);
|
||||
|
||||
// System-notification click handler. The push SW navigates the user back
|
||||
// here with `?email=<id>` (specific email it built the toast from) or
|
||||
@@ -1656,8 +1631,7 @@ export default function Home() {
|
||||
setTabletListVisible(true);
|
||||
}
|
||||
|
||||
const built = buildUnifiedAccounts();
|
||||
const populated = await populateUnifiedAccountMailboxes(built);
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
refreshUnifiedCounts(populated);
|
||||
return;
|
||||
@@ -2004,8 +1978,7 @@ export default function Home() {
|
||||
if (isUnifiedView) {
|
||||
const role = useEmailStore.getState().unifiedRole;
|
||||
if (role) {
|
||||
const built = buildUnifiedAccounts();
|
||||
const populated = await populateUnifiedAccountMailboxes(built);
|
||||
const populated = await buildPopulatedUnifiedAccounts();
|
||||
await fetchUnifiedEmailsAction(populated, role);
|
||||
}
|
||||
return;
|
||||
@@ -2180,7 +2153,7 @@ export default function Home() {
|
||||
const isHorizontalMailLayout = mailLayout === 'horizontal' && !isMobile && !isTablet;
|
||||
const hasViewerContent = showComposer || Boolean(conversationThread) || Boolean(selectedEmail);
|
||||
const shouldCollapseListPane = (isTablet && !tabletListVisible) || (!isMobile && isFocusedMailLayout && hasViewerContent);
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && (isEmbedded || isFocusedMailLayout);
|
||||
const shouldHideViewerPane = !isMobile && !hasViewerContent && isFocusedMailLayout;
|
||||
const shouldHideHorizontalViewerPane = isHorizontalMailLayout && !hasViewerContent;
|
||||
|
||||
// Handle email selection with mobile view switching
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
@@ -77,30 +77,17 @@ function emptyDraft(): RowDraft {
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
const lastEmittedRef = useRef(value);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
if (value === lastEmittedRef.current) return;
|
||||
setDrafts(value.map(entryToDraft))
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
const entries = next.map(draftToEntry).filter((e): e is JmapServerEntry => e !== null);
|
||||
lastEmittedRef.current = entries;
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import { useThemeStore } from '@/stores/theme-store';
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
|
||||
import { useUpdateStore, selectHasUpdate } from '@/stores/update-store';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
// Single-page tab navigation: clicks update a Zustand store. The URL stays
|
||||
// at /admin so React doesn't fire a route transition on every tab switch -
|
||||
@@ -90,9 +90,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
|
||||
const { appLogoLightUrl, appLogoDarkUrl, loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark'
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark'
|
||||
? (appLogoDarkUrl || appLogoLightUrl || loginLogoDarkUrl)
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl);
|
||||
: (appLogoLightUrl || appLogoDarkUrl || loginLogoLightUrl));
|
||||
|
||||
// Match the navigation rail: red for security/deprecated, amber for normal.
|
||||
const hasUpdate = useUpdateStore(selectHasUpdate);
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation';
|
||||
import { Shield } from 'lucide-react';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { useThemeStore } from '@/stores/theme-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export default function AdminLoginPage() {
|
||||
const router = useRouter();
|
||||
@@ -14,7 +14,7 @@ export default function AdminLoginPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { loginLogoLightUrl, loginLogoDarkUrl } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
const logoUrl = resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl;
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? loginLogoDarkUrl : loginLogoLightUrl);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getLocale } from "next-intl/server";
|
||||
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
|
||||
import { ServiceWorkerRegistration } from "@/components/service-worker-registration";
|
||||
import { configManager } from "@/lib/admin/config-manager";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import "../globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -38,7 +39,7 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
formatDetection: {
|
||||
telephone: false,
|
||||
},
|
||||
icons: { icon: faviconUrl },
|
||||
icons: { icon: withBasePath(faviconUrl) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { CheckCircle2, AlertTriangle, AlertCircle, Server, ShieldCheck, KeyRound, FileText, Palette, Lock, ShieldAlert } from 'lucide-react';
|
||||
import { apiFetch, getPathPrefix } from '@/lib/browser-navigation';
|
||||
import { apiFetch, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
type State = 'bootstrap' | 'configured' | 'env-managed';
|
||||
|
||||
@@ -1329,7 +1329,7 @@ function BrandingAsset({
|
||||
}}
|
||||
/>
|
||||
{value ? (
|
||||
<img src={value} alt="" className="max-w-full max-h-full object-contain" />
|
||||
<img src={withBasePath(value)} alt="" className="max-w-full max-h-full object-contain" />
|
||||
) : (
|
||||
<span className="text-[10px] text-muted-foreground text-center px-1">click or drop</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user