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:
@@ -234,6 +234,22 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# your own directory (e.g. http://localhost:3001 for local development).
|
||||
# EXTENSION_DIRECTORY_URL=https://extensions.bulwarkmail.org
|
||||
|
||||
# =============================================================================
|
||||
# Internationalization
|
||||
# =============================================================================
|
||||
# These are build-time variables - to change them with the published Docker
|
||||
# image, rebuild it with --build-arg (see README "Default UI locale").
|
||||
#
|
||||
# Fallback UI locale used when the visitor's Accept-Language header does not
|
||||
# match any supported locale. Defaults to "en".
|
||||
# Supported: cs, da, de, en, es, fr, it, ja, ko, lv, nl, pl, pt, ru, tr, uk, zh
|
||||
# NEXT_PUBLIC_DEFAULT_LOCALE=tr
|
||||
|
||||
# Locale prefix mode for URLs. Recommended "always" when proxying under a
|
||||
# subpath (NEXT_PUBLIC_BASE_PATH) to avoid next-intl rewrite loops.
|
||||
# Values: never (default) | always | as-needed
|
||||
# NEXT_PUBLIC_LOCALE_PREFIX=always
|
||||
|
||||
# =============================================================================
|
||||
# Legacy Build-time Variables (still supported as fallback)
|
||||
# =============================================================================
|
||||
|
||||
@@ -8,6 +8,11 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
# at build time, so it cannot be changed without rebuilding.
|
||||
ARG NEXT_PUBLIC_BASE_PATH=
|
||||
ENV NEXT_PUBLIC_BASE_PATH=$NEXT_PUBLIC_BASE_PATH
|
||||
# Optional: fallback UI locale (e.g. tr, de, fr) used when the visitor's
|
||||
# Accept-Language header does not match any supported locale. Baked in at
|
||||
# build time because next-intl wires it into client-side routing too.
|
||||
ARG NEXT_PUBLIC_DEFAULT_LOCALE=
|
||||
ENV NEXT_PUBLIC_DEFAULT_LOCALE=$NEXT_PUBLIC_DEFAULT_LOCALE
|
||||
# Commit SHA shown in the About screen. .dockerignore excludes .git, so
|
||||
# `git rev-parse` inside the build can't find it - CI must pass it in.
|
||||
ARG GIT_COMMIT=unknown
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -157,7 +157,6 @@ export function EventCard({ event, calendar, variant, onClick, onMouseEnter, onM
|
||||
"w-full h-full text-left rounded-r px-1.5 py-0.5 text-xs overflow-hidden",
|
||||
"hover:opacity-90 transition-opacity cursor-pointer",
|
||||
continuesAfter && "rounded-r-sm",
|
||||
continuesBefore && "-ml-0.5",
|
||||
continuesAfter && "pr-2",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
isBeingDragged && "opacity-50",
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
X, Clock, MapPin, Video, Users, Repeat, Bell, AlignLeft,
|
||||
Pencil, Trash2, Copy, Send, Check,
|
||||
} from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import { getEventDisplayEndDate, getEventEndDate, getEventStartDate } from "@/lib/calendar-utils";
|
||||
import {
|
||||
isOrganizer,
|
||||
getUserParticipantId,
|
||||
@@ -143,6 +143,8 @@ export function EventDetailPopover({
|
||||
const startDate = getEventStartDate(event);
|
||||
const durationMinutes = parseDuration(event.duration);
|
||||
const endDate = getEventEndDate(event);
|
||||
const displayEndDate = getEventDisplayEndDate(event);
|
||||
const isMultiDay = !isSameDay(startDate, displayEndDate);
|
||||
|
||||
const locationName = useMemo(() => {
|
||||
if (!event.locations) return null;
|
||||
@@ -331,16 +333,50 @@ export function EventDetailPopover({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
{isMultiDay ? (
|
||||
event.showWithoutTime ? (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)} –
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(displayEndDate)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(startDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endDate)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{formatTime(endDate)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durationMinutes)})
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startDate)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{formatTime(startDate)} – {formatTime(endDate)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durationMinutes)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Trash2, Check, Users, CalendarDays, Copy, Pencil, Clock, MapPin, Video, Repeat, Bell, AlignLeft, Plus } from "lucide-react";
|
||||
import { format, parseISO, addHours, addDays } from "date-fns";
|
||||
import { format, parseISO, addHours, addDays, isSameDay } from "date-fns";
|
||||
import type { CalendarEvent, Calendar, CalendarParticipant, CalendarEventAlert } from "@/lib/jmap/types";
|
||||
import { parseDuration, getEventColor } from "./event-card";
|
||||
import { buildAllDayDuration, getEventDisplayEndDate, getEventEndDate, getEventStartDate, getPrimaryCalendarId } from "@/lib/calendar-utils";
|
||||
@@ -621,14 +621,42 @@ export function EventModal({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium">{formatEventDate(displayEnd)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(startD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">{formatEventDate(endD)}</span>
|
||||
<span className="text-muted-foreground ml-2">{format(endD, timeDisplayFmt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatEventDate(startD)}</span>
|
||||
{!event.showWithoutTime && (
|
||||
<span className="text-muted-foreground ml-2">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{event.description && (
|
||||
<p className="text-sm text-muted-foreground">{event.description}</p>
|
||||
@@ -742,17 +770,55 @@ export function EventModal({
|
||||
<div className="flex items-start gap-2.5">
|
||||
<Clock className="w-4 h-4 text-muted-foreground mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const displayEnd = getEventDisplayEndDate(event);
|
||||
const multiDay = !isSameDay(startD, displayEnd);
|
||||
if (multiDay && event.showWithoutTime) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">{formatEventDate(startD)} –</div>
|
||||
<div className="font-medium text-foreground">{formatEventDate(displayEnd)}</div>
|
||||
<div className="text-muted-foreground">{t("events.all_day")}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
if (multiDay) {
|
||||
return (
|
||||
<>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="font-medium text-foreground">
|
||||
{formatEventDate(endD)}
|
||||
<span className="ml-1.5 font-normal text-muted-foreground">
|
||||
{format(endD, timeDisplayFmt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
({formatDurationDisplay(durMin)})
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<span className="font-medium text-foreground">
|
||||
{formatEventDate(startD)}
|
||||
</span>
|
||||
{event.showWithoutTime ? (
|
||||
<span className="text-muted-foreground ml-1.5">{t("events.all_day")}</span>
|
||||
) : (
|
||||
<div className="text-muted-foreground">
|
||||
{format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)}
|
||||
<span className="ml-1.5 text-xs">({formatDurationDisplay(durMin)})</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
const currentMailboxRole = mailboxes.find(mb => mb.id === selectedMailbox)?.role;
|
||||
const showRecipient = currentMailboxRole === 'sent' || currentMailboxRole === 'drafts';
|
||||
const sender = showRecipient ? (email.to?.[0] ?? email.from?.[0]) : email.from?.[0];
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const hideJunkAvatarImages = currentMailboxRole === 'junk' && !showAvatarsInJunk;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
@@ -68,8 +70,6 @@ export function EmailListItem({ email, selected, onClick, onDoubleClick, onConte
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useUIStore } from "@/stores/ui-store";
|
||||
import { groupEmailsByThread, sortThreadGroups } from "@/lib/thread-utils";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
|
||||
@@ -121,9 +122,11 @@ export function EmailList({
|
||||
const density = useSettingsStore((state) => state.density);
|
||||
const showPreview = useSettingsStore((state) => state.showPreview);
|
||||
const mailLayout = useSettingsStore((state) => state.mailLayout);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const footerHasMore = hasMore ?? hasMoreEmails;
|
||||
const footerIsLoadingMore = isLoadingMoreItems ?? isLoadingMore;
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// Match the list items: focus layout collapses to multi-line on mobile, so virtualizer estimates must match.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
|
||||
const estimateSize = useCallback(() => {
|
||||
if (isFocusedMailLayout) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { emailExportFilename, attachmentDownloadFilename, DEFAULT_EMAIL_TEMPLATE
|
||||
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
|
||||
import { EMAIL_IFRAME_SANITIZE_CONFIG, collapseBlockedImageContainers, escapeHtml, plainTextToSafeHtml, sanitizeEmailHtml, sanitizePlainTextRenderedHtml } from "@/lib/email-sanitization";
|
||||
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
|
||||
@@ -76,6 +77,7 @@ import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { useAuthStore } from "@/stores/auth-store";
|
||||
import { useAccountStore } from "@/stores/account-store";
|
||||
import { useEmailStore } from "@/stores/email-store";
|
||||
import { useThemeStore } from "@/stores/theme-store";
|
||||
import { EmailIdentityBadge } from "./email-identity-badge";
|
||||
@@ -954,6 +956,7 @@ export function EmailViewer({
|
||||
const { isTablet, isMobile } = useDeviceDetection();
|
||||
const { tabletListVisible } = useUIStore();
|
||||
const { identities, client, isDemoMode, activeAccountId } = useAuthStore();
|
||||
const activeAccount = useAccountStore((s) => s.accounts.find((a) => a.id === activeAccountId));
|
||||
const promptForRescheduleDelayedUntil = useCallback((): string | null => {
|
||||
const value = window.prompt(t('reschedule_prompt'));
|
||||
if (!value) return null;
|
||||
@@ -3301,9 +3304,9 @@ export function EmailViewer({
|
||||
|
||||
if (!email) {
|
||||
if (isDemoMode) {
|
||||
const logoSrc = resolvedTheme === 'dark'
|
||||
const logoSrc = withBasePath(resolvedTheme === 'dark'
|
||||
? '/branding/Bulwark_Logo_with_Lettering_White_and_Color.svg'
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg';
|
||||
: '/branding/Bulwark_Logo_with_Lettering_Dark_Color.svg');
|
||||
return (
|
||||
<div className={cn("flex-1 flex flex-col items-center justify-center bg-gradient-to-br from-muted/30 to-muted/50", className)}>
|
||||
<div className="text-center p-8 max-w-md">
|
||||
@@ -5384,10 +5387,12 @@ export function EmailViewer({
|
||||
<div className="flex items-start" style={{ gap: 'var(--density-item-gap)' }}>
|
||||
<div className="flex-shrink-0">
|
||||
<Avatar
|
||||
name={currentUserName || "You"}
|
||||
email={currentUserEmail || ""}
|
||||
name={activeAccount?.displayName || currentUserName || "You"}
|
||||
email={activeAccount?.email || activeAccount?.username || currentUserEmail || ""}
|
||||
size="lg"
|
||||
className="shadow-sm w-10 h-10"
|
||||
disableFavicon
|
||||
fallbackColor={activeAccount?.avatarColor}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
|
||||
@@ -74,7 +74,9 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
const getAccountById = useAccountStore((state) => state.getAccountById);
|
||||
const accountColor = email.accountId ? getAccountById(email.accountId)?.avatarColor : undefined;
|
||||
const isChecked = selectedEmailIds.has(email.id);
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(email.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = email.isScheduled && email.scheduledSendAt
|
||||
@@ -95,8 +97,6 @@ const SingleEmailItem = React.forwardRef<HTMLDivElement, SingleEmailItemProps>(
|
||||
sourceMailboxId: selectedMailbox,
|
||||
});
|
||||
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
|
||||
const { onTouchStart, onTouchEnd, onTouchMove, onTouchCancel, isPressed } = useLongPress(
|
||||
useCallback((pos) => {
|
||||
onContextMenu?.(
|
||||
@@ -404,7 +404,8 @@ export const ThreadListItem = React.forwardRef<HTMLDivElement, ThreadListItemPro
|
||||
const showAvatarsInJunk = useSettingsStore((state) => state.showAvatarsInJunk);
|
||||
const isMobile = useUIStore((state) => state.isMobile);
|
||||
const { latestEmail, participantNames, hasUnread, hasStarred, hasAttachment, hasAnswered, hasForwarded, emailCount } = thread;
|
||||
const isFocusedMailLayout = mailLayout === 'focus';
|
||||
// The horizontal one-line "focus" layout doesn't fit on narrow screens; fall back to multi-line on mobile.
|
||||
const isFocusedMailLayout = mailLayout === 'focus' && !isMobile;
|
||||
const trimmedPreview = stripInvisibleLeading(latestEmail.preview ?? '');
|
||||
const inlinePreview = showPreview && trimmedPreview ? ` ${trimmedPreview}` : '';
|
||||
const scheduledSendLabel = latestEmail.isScheduled && latestEmail.scheduledSendAt
|
||||
|
||||
@@ -21,7 +21,7 @@ import { getMaxAccounts } from "@/lib/account-utils";
|
||||
import { cn, formatFileSize } from "@/lib/utils";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
|
||||
import { apiFetch, getPathPrefix } from "@/lib/browser-navigation";
|
||||
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
|
||||
interface NavItem {
|
||||
@@ -444,7 +444,7 @@ export function NavigationRail({
|
||||
)}
|
||||
>
|
||||
{(() => {
|
||||
const logoUrl = resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl);
|
||||
const logoUrl = withBasePath(resolvedTheme === 'dark' ? (appLogoDarkUrl || appLogoLightUrl) : (appLogoLightUrl || appLogoDarkUrl));
|
||||
return logoUrl ? (
|
||||
<div className="flex items-center justify-center py-3 px-1">
|
||||
<img
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, ReactNode } from "react";
|
||||
import { useState, useEffect, useMemo, ReactNode } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
@@ -736,15 +736,20 @@ export function Sidebar({
|
||||
// pane.
|
||||
const hideAccountSwitcher = useSettingsStore(s => s.hideAccountSwitcher) || isEmbedded;
|
||||
const enableUnifiedMailbox = useSettingsStore(s => s.enableUnifiedMailbox);
|
||||
const includeGroupInUnified = useSettingsStore(s => s.includeGroupInUnified);
|
||||
const colorfulSidebarIcons = useSettingsStore(s => s.colorfulSidebarIcons);
|
||||
const tagCounts = useEmailStore(s => s.tagCounts);
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const connectedAccounts = accounts.filter(a => a.isConnected);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
// Pro shell treats the unified mailbox as a core part of the multi-account
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. The
|
||||
// 2+ account requirement still applies - with a single account the
|
||||
// unified counts would just duplicate that account's inbox.
|
||||
const showUnified = (multiAccountMode || enableUnifiedMailbox) && connectedAccounts.length > 1;
|
||||
// UI, so it ignores the user-facing `enableUnifiedMailbox` toggle. With a
|
||||
// single account we still surface unified when the user has opted into
|
||||
// merging group/shared inboxes — otherwise the counts would just duplicate
|
||||
// the one inbox.
|
||||
const showUnified =
|
||||
(multiAccountMode || enableUnifiedMailbox) &&
|
||||
(connectedAccounts.length > 1 || (includeGroupInUnified && hasGroupInboxes));
|
||||
const { unifiedCounts } = useEmailStore();
|
||||
const t = useTranslations('sidebar');
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, Download } from "lucide-react";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface BeforeInstallPromptEvent extends Event {
|
||||
prompt: () => Promise<void>;
|
||||
@@ -58,7 +59,8 @@ export function PWAInstallPrompt() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const logoSrc = appLogoLightUrl || faviconUrl;
|
||||
const logoSrc = withBasePath(appLogoLightUrl || faviconUrl);
|
||||
const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-50 bg-white dark:bg-neutral-900 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-800 p-4 max-w-sm animate-in slide-in-from-bottom-4">
|
||||
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() {
|
||||
)}
|
||||
{logoSrc && (
|
||||
<img
|
||||
src={appLogoDarkUrl || faviconUrl}
|
||||
src={darkLogoSrc}
|
||||
alt={appName}
|
||||
className="w-8 h-8 shrink-0 object-contain hidden dark:block"
|
||||
/>
|
||||
|
||||
@@ -4,17 +4,14 @@ import { useTranslations } from 'next-intl';
|
||||
import { useCalendarStore, CalendarViewMode } from '@/stores/calendar-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section';
|
||||
|
||||
export function CalendarSettings() {
|
||||
const t = useTranslations('calendar.settings');
|
||||
const tViews = useTranslations('calendar.views');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { viewMode, setViewMode } = useCalendarStore();
|
||||
const {
|
||||
timeFormat,
|
||||
firstDayOfWeek,
|
||||
showTimeInMonthView,
|
||||
showWeekNumbers,
|
||||
enableCalendarTasks,
|
||||
@@ -40,28 +37,6 @@ export function CalendarSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('week_starts_on')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as 0 | 1)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as '12h' | '24h')}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format_12h') },
|
||||
{ value: '24h', label: t('time_format_24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem
|
||||
label={t('show_time_in_month_view')}
|
||||
description={t('show_time_in_month_view_desc')}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Folder, FolderOpen, FileText, FileCode, ImageIcon, FileAudio, File, Hom
|
||||
import { SettingsSection, SettingItem, ToggleSwitch, RadioGroup } from "./settings-section";
|
||||
import { loadFilesSettings, saveFilesSettings, type FilesSettings, type FolderLayout } from "@/components/files/files-settings-dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
interface SampleFile {
|
||||
name: string;
|
||||
@@ -95,7 +96,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-4 h-4 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "sm")
|
||||
) : null}
|
||||
@@ -125,7 +126,7 @@ function FilesSettingsPreview({ settings }: { settings: FilesSettings }) {
|
||||
)}
|
||||
>
|
||||
{settings.showThumbnails && file.thumbnailUrl ? (
|
||||
<img src={file.thumbnailUrl} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
<img src={withBasePath(file.thumbnailUrl)} alt="" className="w-8 h-8 rounded object-cover flex-shrink-0" />
|
||||
) : settings.showIcons ? (
|
||||
getPreviewIcon(file, settings.coloredIcons, "lg")
|
||||
) : (
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { SettingsSection, SettingItem } from './settings-section';
|
||||
import { useLocaleStore } from '@/stores/locale-store';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import type { DateFormat, TimeFormat, FirstDayOfWeek } from '@/stores/settings-store';
|
||||
import { formatDate } from '@/lib/utils';
|
||||
import { SettingsSection, SettingItem, Select, RadioGroup } from './settings-section';
|
||||
|
||||
export function LanguageSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const t = useTranslations('settings.language_region');
|
||||
const tDays = useTranslations('calendar.days');
|
||||
|
||||
const { dateFormat, timeFormat, firstDayOfWeek, updateSetting } = useSettingsStore();
|
||||
|
||||
// Subscribe to locale changes so the preview re-renders on language switch
|
||||
// (formatDate reads it via getState() and would otherwise stay stale).
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
|
||||
const preview = useMemo(() => {
|
||||
// Build sample timestamps for each bucket so users see what their pick
|
||||
// will look like in practice. Use offsets relative to "now" so the
|
||||
// bucketing is stable even though the wall-clock keeps moving.
|
||||
void locale; void dateFormat; void timeFormat;
|
||||
const now = new Date();
|
||||
const today = new Date(now);
|
||||
today.setHours(15, 31, 0, 0);
|
||||
const thisWeek = new Date(now);
|
||||
thisWeek.setDate(now.getDate() - 2);
|
||||
thisWeek.setHours(15, 31, 0, 0);
|
||||
const older = new Date(now);
|
||||
older.setMonth(now.getMonth() - 2);
|
||||
older.setHours(15, 31, 0, 0);
|
||||
return {
|
||||
today: formatDate(today),
|
||||
thisWeek: formatDate(thisWeek),
|
||||
older: formatDate(older),
|
||||
};
|
||||
}, [locale, dateFormat, timeFormat]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('language.label')} description={t('language.description')}>
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
<SettingItem label={t('language.label')} description={t('language.description')}>
|
||||
<LanguageSwitcher />
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('date_format.label')} description={t('date_format.description')}>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Select
|
||||
value={dateFormat}
|
||||
onChange={(value) => updateSetting('dateFormat', value as DateFormat)}
|
||||
options={[
|
||||
{ value: 'smart', label: t('date_format.smart') },
|
||||
{ value: 'relative', label: t('date_format.relative') },
|
||||
{ value: 'full', label: t('date_format.full') },
|
||||
]}
|
||||
/>
|
||||
<div className="text-xs text-muted-foreground text-right space-y-0.5 font-mono">
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_today')} </span>
|
||||
<span className="text-foreground/90">{preview.today}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_this_week')} </span>
|
||||
<span className="text-foreground/90">{preview.thisWeek}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="opacity-70">{t('date_format.preview_older')} </span>
|
||||
<span className="text-foreground/90">{preview.older}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('time_format.label')} description={t('time_format.description')}>
|
||||
<RadioGroup
|
||||
value={timeFormat}
|
||||
onChange={(value) => updateSetting('timeFormat', value as TimeFormat)}
|
||||
options={[
|
||||
{ value: '12h', label: t('time_format.12h') },
|
||||
{ value: '24h', label: t('time_format.24h') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
<SettingItem label={t('first_day.label')} description={t('first_day.description')}>
|
||||
<Select
|
||||
value={firstDayOfWeek.toString()}
|
||||
onChange={(value) => updateSetting('firstDayOfWeek', parseInt(value) as FirstDayOfWeek)}
|
||||
options={[
|
||||
{ value: '1', label: tDays('monday') },
|
||||
{ value: '0', label: tDays('sunday') },
|
||||
]}
|
||||
/>
|
||||
</SettingItem>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useSettingsStore, type ToolbarPosition, type MailLayout } from '@/stores/settings-store';
|
||||
import { SettingsSection, SettingItem, RadioGroup, ToggleSwitch } from './settings-section';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { useAccountStore } from '@/stores/account-store';
|
||||
import { useEmailStore } from '@/stores/email-store';
|
||||
|
||||
const MAIL_LAYOUT_PREVIEW_ROWS = [
|
||||
{ sender: 'Alice', subject: 'Quarterly roadmap', preview: 'The draft is ready for review.', selected: false },
|
||||
@@ -115,9 +117,11 @@ function MailLayoutPreview({
|
||||
export function LayoutSettings() {
|
||||
const t = useTranslations('settings.appearance');
|
||||
const tEmail = useTranslations('settings.email_behavior');
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { toolbarPosition, showToolbarLabels, hideAccountSwitcher, showRailAccountList, enableUnifiedMailbox, includeGroupInUnified, colorfulSidebarIcons, mailLayout, proInterface, updateSetting } = useSettingsStore();
|
||||
const { isSettingLocked, isSettingHidden } = usePolicyStore();
|
||||
const accounts = useAccountStore(s => s.accounts);
|
||||
const mailboxes = useEmailStore(s => s.mailboxes);
|
||||
const hasGroupInboxes = useMemo(() => mailboxes.some(m => m.isShared), [mailboxes]);
|
||||
|
||||
return (
|
||||
<SettingsSection title={t('title')} description={t('description')}>
|
||||
@@ -177,10 +181,11 @@ export function LayoutSettings() {
|
||||
/>
|
||||
</SettingItem>
|
||||
|
||||
{accounts.length > 1 && (
|
||||
{(accounts.length > 1 || hasGroupInboxes) && !isSettingHidden('enableUnifiedMailbox') && (
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.label')}
|
||||
description={t('unified_mailbox.description')}
|
||||
locked={isSettingLocked('enableUnifiedMailbox')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={enableUnifiedMailbox}
|
||||
@@ -189,6 +194,21 @@ export function LayoutSettings() {
|
||||
</SettingItem>
|
||||
)}
|
||||
|
||||
{enableUnifiedMailbox && hasGroupInboxes && !isSettingHidden('includeGroupInUnified') && (
|
||||
<div className="ml-4 border-l-2 border-border pl-4 -mt-2">
|
||||
<SettingItem
|
||||
label={t('unified_mailbox.include_group.label')}
|
||||
description={t('unified_mailbox.include_group.description')}
|
||||
locked={isSettingLocked('includeGroupInUnified')}
|
||||
>
|
||||
<ToggleSwitch
|
||||
checked={includeGroupInUnified}
|
||||
onChange={(v) => updateSetting('includeGroupInUnified', v)}
|
||||
/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SettingItem label={t('pro_interface.label')} description={t('pro_interface.description')}>
|
||||
<ToggleSwitch
|
||||
checked={proInterface}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useContactStore, getContactPhotoUri } from "@/stores/contact-store";
|
||||
import { useConfig } from "@/hooks/use-config";
|
||||
import { avatarHooks } from "@/lib/plugin-hooks";
|
||||
import { withBasePath } from "@/lib/browser-navigation";
|
||||
|
||||
const IS_DEV = process.env.NODE_ENV !== "production";
|
||||
|
||||
@@ -236,7 +237,7 @@ export function Avatar({ name, email, contactPhotoUri, size = "md", className, d
|
||||
const customAvatar = devMode && email ? CUSTOM_AVATARS[email.toLowerCase()] : null;
|
||||
const pluginAvatar = pluginAvatarFailed ? null : pluginAvatarUrl;
|
||||
const photoSrc = resolvedContactPhoto || pluginAvatar || customAvatar || profilePic || null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? `/api/favicon?domain=${encodeURIComponent(faviconDomain!)}` : null;
|
||||
const faviconSrc = !imgError && !domainFailed && showFavicon ? withBasePath(`/api/favicon?domain=${encodeURIComponent(faviconDomain!)}`) : null;
|
||||
const imgSrc = disableImages ? null : (photoSrc || faviconSrc);
|
||||
const isFavicon = imgSrc !== null && imgSrc === faviconSrc;
|
||||
|
||||
|
||||
+27
-19
@@ -81,20 +81,8 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
// Virtual nodes (shared folder headers) cannot be drop targets
|
||||
if (mailbox.id.startsWith("shared-")) return false;
|
||||
|
||||
// Shared (delegated) mailboxes still require the source to belong to the
|
||||
// same delegating account. Real cross-account moves between primary
|
||||
// accounts go through the cross-account path further down, but the
|
||||
// shared-folder semantics here are about ACLs rather than transport, so
|
||||
// they remain disallowed.
|
||||
if (mailbox.isShared && draggedEmails[0]) {
|
||||
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
if (sourceMb?.accountId !== mailbox.accountId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [isDragging, mailbox, sourceMailboxId, draggedEmails]);
|
||||
}, [isDragging, mailbox, sourceMailboxId]);
|
||||
|
||||
const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
@@ -154,18 +142,38 @@ export function useMailboxDrop({ mailbox, onDropComplete, onSuccess, onError }:
|
||||
bySource.get(srcAccountId)!.push(id);
|
||||
}
|
||||
|
||||
// Decide whether to route via the cross-account (blob copy + import)
|
||||
// pipeline. Two cases require it:
|
||||
// 1. Destination is a delegated/shared mailbox whose owner JMAP
|
||||
// account differs from the source mailbox's JMAP account. There's
|
||||
// no atomic Email/set across accounts, even via the same client.
|
||||
// 2. Destination is a primary mailbox on a different connected local
|
||||
// account than the source — the historical multi-account case.
|
||||
const sourceMb = useEmailStore.getState().mailboxes.find(mb => mb.id === sourceMailboxId);
|
||||
const sourceJmapAccountId = sourceMb?.accountId;
|
||||
const destJmapAccountId = mailbox.accountId;
|
||||
const sourceAccountIds = Array.from(bySource.keys());
|
||||
const isCrossAccount =
|
||||
!!destAccountId &&
|
||||
|
||||
const isJmapCrossAccount =
|
||||
!!sourceJmapAccountId &&
|
||||
!!destJmapAccountId &&
|
||||
sourceJmapAccountId !== destJmapAccountId;
|
||||
const isLocalCrossAccount =
|
||||
!mailbox.isShared &&
|
||||
!!destAccountId &&
|
||||
sourceAccountIds.some((src) => src !== destAccountId);
|
||||
const isCrossAccount = isJmapCrossAccount || isLocalCrossAccount;
|
||||
|
||||
if (isCrossAccount) {
|
||||
// JMAP can't natively move an email between primary accounts, so the
|
||||
// store reuploads each source blob into the destination account and
|
||||
// then deletes the original.
|
||||
if (!destAccountId) {
|
||||
throw new Error('Could not resolve destination account');
|
||||
}
|
||||
// For a shared destination there is no separately-connected client
|
||||
// for the owner; we reuse the viewing user's client but tell the
|
||||
// import call to target the owner's JMAP account.
|
||||
const jmapDestId = mailbox.originalId || mailbox.id;
|
||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId);
|
||||
const destJmapOverride = mailbox.isShared ? mailbox.accountId : undefined;
|
||||
await crossAccountMoveEmails(bySource, destAccountId, jmapDestId, destJmapOverride);
|
||||
} else {
|
||||
// Single-account or same-account-shared move: bulk JMAP request.
|
||||
await moveEmailsToMailbox(client, emailIds, mailbox.id);
|
||||
|
||||
+14
-2
@@ -12,9 +12,21 @@ const localePrefix = (process.env.NEXT_PUBLIC_LOCALE_PREFIX ?? 'never') as
|
||||
| 'always'
|
||||
| 'as-needed';
|
||||
|
||||
const SUPPORTED_LOCALES = ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'] as const;
|
||||
|
||||
// Fallback locale used when the visitor's Accept-Language header does not
|
||||
// match any supported locale (and no NEXT_LOCALE cookie is set yet). Admins
|
||||
// set this via NEXT_PUBLIC_DEFAULT_LOCALE at build time to localise greenfield
|
||||
// deployments without having every user change their preference manually.
|
||||
const envDefaultLocale = process.env.NEXT_PUBLIC_DEFAULT_LOCALE?.trim();
|
||||
const resolvedDefaultLocale =
|
||||
envDefaultLocale && (SUPPORTED_LOCALES as readonly string[]).includes(envDefaultLocale)
|
||||
? (envDefaultLocale as (typeof SUPPORTED_LOCALES)[number])
|
||||
: 'en';
|
||||
|
||||
export const routing = defineRouting({
|
||||
locales: ['cs', 'da', 'de', 'en', 'es', 'fr', 'it', 'ja', 'ko', 'lv', 'nl', 'pl', 'pt', 'ru', 'tr', 'uk', 'zh'],
|
||||
defaultLocale: 'en',
|
||||
locales: SUPPORTED_LOCALES,
|
||||
defaultLocale: resolvedDefaultLocale,
|
||||
localePrefix
|
||||
});
|
||||
|
||||
|
||||
@@ -75,6 +75,26 @@ export function apiFetch(input: string, init?: RequestInit): Promise<Response> {
|
||||
return fetch(input, init);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount-prefix-aware wrapper for URL strings used in `<img src>`, `<link href>`,
|
||||
* `window.location.*`, etc. — anything the browser resolves itself, where
|
||||
* `apiFetch` can't help.
|
||||
*
|
||||
* Idempotent: passing an already-prefixed value, an external URL, a
|
||||
* protocol-relative URL, or an empty/falsy value returns it unchanged. So it's
|
||||
* safe to wrap admin-configurable values that might be either a local path
|
||||
* (`/branding/foo.svg`, `/api/admin/branding/...`) or a full URL.
|
||||
*/
|
||||
export function withBasePath(url: string | null | undefined): string {
|
||||
if (!url) return url ?? '';
|
||||
if (url.charCodeAt(0) !== 47) return url; // not absolute (e.g. https://, data:, blob:)
|
||||
if (url.charCodeAt(1) === 47) return url; // protocol-relative //cdn...
|
||||
const prefix = getPathPrefix();
|
||||
if (!prefix) return url;
|
||||
if (url === prefix || url.startsWith(prefix + '/')) return url;
|
||||
return prefix + url;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Extracts the locale from the current URL, skipping any mount prefix.
|
||||
|
||||
@@ -283,6 +283,6 @@ export interface IJMAPClient {
|
||||
copyFileNode(id: string, newName: string, parentId: string | null): Promise<FileNode>;
|
||||
|
||||
// ── S/MIME raw-email helpers ──────────────────────────────────
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>): Promise<string>;
|
||||
importRawEmail(blob: Blob, mailboxIds: Record<string, boolean>, keywords?: Record<string, boolean>, accountId?: string): Promise<string>;
|
||||
submitEmail(emailId: string, identityId: string): Promise<void>;
|
||||
}
|
||||
|
||||
+17
-7
@@ -2871,7 +2871,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
}
|
||||
|
||||
async uploadBlob(file: File): Promise<{ blobId: string; size: number; type: string }> {
|
||||
async uploadBlob(file: File, accountId?: string): Promise<{ blobId: string; size: number; type: string }> {
|
||||
if (!this.session) {
|
||||
throw new Error('Not connected. Call connect() first.');
|
||||
}
|
||||
@@ -2881,7 +2881,8 @@ export class JMAPClient implements IJMAPClient {
|
||||
throw new Error('Upload URL not available');
|
||||
}
|
||||
|
||||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(this.accountId));
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
const finalUploadUrl = uploadUrl.replace('{accountId}', encodeURIComponent(targetAccountId));
|
||||
const response = await this.authenticatedFetch(finalUploadUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
@@ -2911,7 +2912,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
}
|
||||
|
||||
// Nested format: { [accountId]: { blobId, type, size } }
|
||||
const blobInfo = result[this.accountId];
|
||||
const blobInfo = result[targetAccountId];
|
||||
if (blobInfo?.blobId) {
|
||||
return {
|
||||
blobId: blobInfo.blobId,
|
||||
@@ -5464,20 +5465,29 @@ export class JMAPClient implements IJMAPClient {
|
||||
return response.arrayBuffer();
|
||||
}
|
||||
|
||||
/** Import a raw MIME message blob into the account. */
|
||||
/**
|
||||
* Import a raw MIME message blob into the account. Pass `accountId` to
|
||||
* target a delegated account the caller has rights on (e.g. importing into
|
||||
* a shared mailbox owned by another user). When omitted, falls back to the
|
||||
* client's own primary account.
|
||||
*/
|
||||
async importRawEmail(
|
||||
blob: Blob,
|
||||
mailboxIds: Record<string, boolean>,
|
||||
keywords?: Record<string, boolean>,
|
||||
accountId?: string,
|
||||
): Promise<string> {
|
||||
// First upload the blob
|
||||
const targetAccountId = accountId || this.accountId;
|
||||
// First upload the blob. Blob uploads are scoped to an account too —
|
||||
// when importing into a delegated account, upload there so the resulting
|
||||
// blobId is visible to Email/import on that account.
|
||||
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
|
||||
const { blobId } = await this.uploadBlob(file);
|
||||
const { blobId } = await this.uploadBlob(file, targetAccountId);
|
||||
|
||||
// Then import via Email/import
|
||||
const response = await this.request([
|
||||
['Email/import', {
|
||||
accountId: this.accountId,
|
||||
accountId: targetAccountId,
|
||||
emails: {
|
||||
'smime-import': {
|
||||
blobId,
|
||||
|
||||
+32
-4
@@ -6,6 +6,11 @@ export interface UnifiedAccountClient {
|
||||
accountLabel: string;
|
||||
client: IJMAPClient;
|
||||
mailboxes: Mailbox[];
|
||||
// When true, this entry represents a group/shared account owned by
|
||||
// `accountId` but accessed through someone else's `client`. JMAP requests
|
||||
// must use the mailbox's `originalId` and explicitly target this accountId
|
||||
// so the server routes to the owner's data.
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
export interface UnifiedFetchResult {
|
||||
@@ -60,10 +65,11 @@ export async function fetchUnifiedEmails(
|
||||
const mailbox = findMailboxByRole(account.mailboxes, role);
|
||||
if (!mailbox) return null;
|
||||
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
try {
|
||||
const result = await account.client.getEmails(
|
||||
mailbox.id,
|
||||
undefined,
|
||||
jmapMailboxId,
|
||||
jmapAccountId,
|
||||
limit,
|
||||
position,
|
||||
);
|
||||
@@ -131,7 +137,8 @@ export async function searchUnifiedEmails(
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.searchEmails(query, mailbox.id, undefined, limit, position);
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
return account.client.searchEmails(query, jmapMailboxId, jmapAccountId, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,10 +156,31 @@ export async function advancedSearchUnifiedEmails(
|
||||
position: number,
|
||||
): Promise<UnifiedFetchResult> {
|
||||
return fanOutUnifiedQuery(accounts, role, async (account, mailbox) => {
|
||||
return account.client.advancedSearchEmails(filterFor(mailbox.id), undefined, limit, position);
|
||||
const { jmapMailboxId, jmapAccountId } = resolveJmapTarget(account, mailbox);
|
||||
return account.client.advancedSearchEmails(filterFor(jmapMailboxId), jmapAccountId, limit, position);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the JMAP-side mailbox id and accountId for a mailbox living inside
|
||||
* a UnifiedAccountClient. For personal-account entries we use the JMAP id as
|
||||
* returned by the primary client; for shared-owner entries the mailbox id is
|
||||
* namespaced (`${ownerId}:${origId}`) so we must use `originalId` and pass the
|
||||
* owner's accountId through the request.
|
||||
*/
|
||||
function resolveJmapTarget(
|
||||
account: UnifiedAccountClient,
|
||||
mailbox: Mailbox,
|
||||
): { jmapMailboxId: string; jmapAccountId: string | undefined } {
|
||||
if (account.isShared) {
|
||||
return {
|
||||
jmapMailboxId: mailbox.originalId ?? mailbox.id,
|
||||
jmapAccountId: account.accountId,
|
||||
};
|
||||
}
|
||||
return { jmapMailboxId: mailbox.id, jmapAccountId: undefined };
|
||||
}
|
||||
|
||||
async function fanOutUnifiedQuery(
|
||||
accounts: UnifiedAccountClient[],
|
||||
role: UnifiedMailboxRole,
|
||||
|
||||
+76
-12
@@ -3,6 +3,8 @@ import { twMerge } from "tailwind-merge";
|
||||
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
|
||||
import type { UnifiedMailboxRole } from "./jmap/types";
|
||||
import { debug } from "./debug";
|
||||
import { useLocaleStore } from "@/stores/locale-store";
|
||||
import { useSettingsStore } from "@/stores/settings-store";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
@@ -40,24 +42,86 @@ export function generateUUID(): string {
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a received-at date for the email list. The output style is
|
||||
* controlled by the `dateFormat` user setting:
|
||||
*
|
||||
* - `smart` (default) — locale-aware, age-bucketed:
|
||||
* today → time only ("15:31" or "3:31 PM")
|
||||
* last 7 days → short weekday+time ("Fr 15:31", "Fri 3:31 PM")
|
||||
* older → full locale date ("28.04.2026", "04/28/2026")
|
||||
* - `relative` — legacy en-US relative format ("1h ago", "2d ago").
|
||||
* - `full` — always the full locale date+time.
|
||||
*
|
||||
* Both the locale (from the language picker) and 12h/24h preference are
|
||||
* read via `getState()` so this stays SSR-safe.
|
||||
*/
|
||||
export function formatDate(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
const localeRaw = useLocaleStore.getState().locale;
|
||||
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : "en";
|
||||
// `en` alone resolves to en-US in Intl; everything else uses the language
|
||||
// subtag as-is and lets the runtime pick a sensible default region.
|
||||
const intlLocale = locale === "en" ? "en-US" : locale;
|
||||
const { dateFormat, timeFormat } = useSettingsStore.getState();
|
||||
const hour12 = timeFormat === "12h";
|
||||
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
if (dateFormat === "relative") {
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (minutes < 1) return "Just now";
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return d.toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
if (dateFormat === "full") {
|
||||
return d.toLocaleString(intlLocale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
}
|
||||
|
||||
// 'smart' (default)
|
||||
const timeStr = d.toLocaleTimeString(intlLocale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12,
|
||||
});
|
||||
|
||||
const isSameDay =
|
||||
d.getFullYear() === now.getFullYear() &&
|
||||
d.getMonth() === now.getMonth() &&
|
||||
d.getDate() === now.getDate();
|
||||
if (isSameDay) return timeStr;
|
||||
|
||||
const daysAgo = Math.floor((now.getTime() - d.getTime()) / 86400000);
|
||||
if (daysAgo < 7) {
|
||||
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
|
||||
// strip it so the result reads cleanly next to the time.
|
||||
const weekday = d
|
||||
.toLocaleDateString(intlLocale, { weekday: "short" })
|
||||
.replace(/\.$/, "");
|
||||
return `${weekday} ${timeStr}`;
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(intlLocale, {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { getActiveAccountSlotHeaders } from '@/lib/auth/active-account-slot';
|
||||
import { apiFetch, withBasePath } from '@/lib/browser-navigation';
|
||||
|
||||
export interface WebDAVResource {
|
||||
href: string;
|
||||
@@ -32,7 +33,7 @@ export class WebDAVClient {
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
return fetch(this.proxyUrl, {
|
||||
return apiFetch(this.proxyUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: options?.body,
|
||||
@@ -118,7 +119,7 @@ export class WebDAVClient {
|
||||
// Use XMLHttpRequest for progress tracking
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', this.proxyUrl);
|
||||
xhr.open('POST', withBasePath(this.proxyUrl));
|
||||
xhr.setRequestHeader('X-WebDAV-Method', 'PUT');
|
||||
xhr.setRequestHeader('X-WebDAV-Path', path);
|
||||
const slotHeaders = getActiveAccountSlotHeaders();
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Pokračovat v úpravách",
|
||||
"tabs": {
|
||||
"appearance": "Vzhled",
|
||||
"language": "Jazyk a region",
|
||||
"language": "Jazyk, region a čas",
|
||||
"email": "Chování e-mailu",
|
||||
"composer": "Psaní zpráv",
|
||||
"privacy": "Soukromí a bezpečnost",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Sjednocená schránka",
|
||||
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů"
|
||||
"description": "Zobrazovat sloučené složky (Doručené, Odeslané atd.) ze všech připojených účtů",
|
||||
"include_group": {
|
||||
"label": "Zahrnout skupinové schránky",
|
||||
"description": "Zahrnout do sjednoceného zobrazení také sdílené/skupinové schránky."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Barevné ikony postranního panelu",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Jazyk a region",
|
||||
"description": "Nakonfigurujte jazykové a místní předvolby",
|
||||
"title": "Jazyk, region a čas",
|
||||
"description": "Jazyk, formát data, formát času a další regionální předvolby",
|
||||
"language": {
|
||||
"label": "Jazyk",
|
||||
"description": "Vyberte preferovaný jazyk",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formát data",
|
||||
"description": "Jak se mají zobrazovat data",
|
||||
"regional": "Místní",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Vlastní"
|
||||
"description": "Jak se zobrazují data v seznamu e-mailů",
|
||||
"smart": "Chytrý (regionální)",
|
||||
"relative": "Relativní (před 1 h, před 2 d)",
|
||||
"full": "Vždy úplné datum",
|
||||
"preview_today": "Dnes:",
|
||||
"preview_this_week": "Tento týden:",
|
||||
"preview_older": "Starší:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formát času",
|
||||
|
||||
+15
-8
@@ -785,7 +785,7 @@
|
||||
"search_no_results": "Ingen match i indstillinger",
|
||||
"tabs": {
|
||||
"appearance": "Udseende",
|
||||
"language": "Sprog & region",
|
||||
"language": "Sprog, region & tid",
|
||||
"email": "E-mail-adfærd",
|
||||
"composer": "Komponist",
|
||||
"privacy": "Privatliv & sikkerhed",
|
||||
@@ -877,7 +877,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Samlet postkasse",
|
||||
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti"
|
||||
"description": "Vis samlede mapper (Indbakke, Sendt osv.) på tværs af alle tilknyttede konti",
|
||||
"include_group": {
|
||||
"label": "Inkluder gruppepostkasser",
|
||||
"description": "Inkluder også delte/gruppepostkasser i den samlede visning."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Farverige sidepane-ikoner",
|
||||
@@ -961,8 +965,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Sprog & region",
|
||||
"description": "Konfigurér sprog og regionale præferencer",
|
||||
"title": "Sprog, region & tid",
|
||||
"description": "Sprog, datoformat, tidsformat og andre regionale indstillinger",
|
||||
"language": {
|
||||
"label": "Sprog",
|
||||
"description": "Vælg dit foretrukne sprog",
|
||||
@@ -971,10 +975,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datoformat",
|
||||
"description": "Hvordan datoer skal vises",
|
||||
"regional": "Regionalt",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Brugerdefineret"
|
||||
"description": "Sådan vises datoer på e-mail-listen",
|
||||
"smart": "Smart (regionalt)",
|
||||
"relative": "Relativ (for 1 t siden, for 2 d siden)",
|
||||
"full": "Altid fuld dato",
|
||||
"preview_today": "I dag:",
|
||||
"preview_this_week": "Denne uge:",
|
||||
"preview_older": "Ældre:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Tidsformat",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Weiter bearbeiten",
|
||||
"tabs": {
|
||||
"appearance": "Darstellung",
|
||||
"language": "Sprache & Region",
|
||||
"language": "Sprache, Region & Zeit",
|
||||
"email": "E-Mail-Verhalten",
|
||||
"composer": "Editor",
|
||||
"privacy": "Datenschutz & Sicherheit",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Gemeinsames Postfach",
|
||||
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen"
|
||||
"description": "Kombinierte Ordner (Posteingang, Gesendet usw.) für alle verbundenen Konten anzeigen",
|
||||
"include_group": {
|
||||
"label": "Gruppenpostfächer einbeziehen",
|
||||
"description": "Gemeinsam genutzte/Gruppenpostfächer ebenfalls in die vereinheitlichte Ansicht aufnehmen."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Farbige Seitenleistensymbole",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Sprache & Region",
|
||||
"description": "Konfigurieren Sie Sprach- und Regionaleinstellungen",
|
||||
"title": "Sprache, Region & Zeit",
|
||||
"description": "Sprache, Datums- und Zeitformat sowie weitere regionale Einstellungen",
|
||||
"language": {
|
||||
"label": "Sprache",
|
||||
"description": "Wählen Sie Ihre bevorzugte Sprache",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datumsformat",
|
||||
"description": "Wie Daten angezeigt werden sollen",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Benutzerdefiniert"
|
||||
"description": "Wie Daten in der E-Mail-Liste angezeigt werden",
|
||||
"smart": "Intelligent (gebietsschemaabhängig)",
|
||||
"relative": "Relativ (vor 1 Std., vor 2 Tagen)",
|
||||
"full": "Immer vollständiges Datum",
|
||||
"preview_today": "Heute:",
|
||||
"preview_this_week": "Diese Woche:",
|
||||
"preview_older": "Älter:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Zeitformat",
|
||||
|
||||
+15
-8
@@ -785,7 +785,7 @@
|
||||
"search_no_results": "No matching settings",
|
||||
"tabs": {
|
||||
"appearance": "Appearance",
|
||||
"language": "Language & Region",
|
||||
"language": "Language, Region & Time",
|
||||
"email": "Email Behavior",
|
||||
"composer": "Composer",
|
||||
"privacy": "Privacy & Security",
|
||||
@@ -877,7 +877,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Unified Mailbox",
|
||||
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts"
|
||||
"description": "Show combined folders (Inbox, Sent, etc.) across all connected accounts",
|
||||
"include_group": {
|
||||
"label": "Include group inboxes",
|
||||
"description": "Also merge shared/group inboxes into the unified view."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Colorful Sidebar Icons",
|
||||
@@ -961,8 +965,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Language & Region",
|
||||
"description": "Configure language and regional preferences",
|
||||
"title": "Language, Region & Time",
|
||||
"description": "Language, date format, time format, and other regional preferences",
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"description": "Choose your preferred language",
|
||||
@@ -971,10 +975,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Date Format",
|
||||
"description": "How dates should be displayed",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Custom"
|
||||
"description": "How dates are shown in the email list",
|
||||
"smart": "Smart (locale-aware)",
|
||||
"relative": "Relative (1h ago, 2d ago)",
|
||||
"full": "Always full date",
|
||||
"preview_today": "Today:",
|
||||
"preview_this_week": "This week:",
|
||||
"preview_older": "Older:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Time Format",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Seguir editando",
|
||||
"tabs": {
|
||||
"appearance": "Apariencia",
|
||||
"language": "Idioma y Región",
|
||||
"language": "Idioma, Región y Hora",
|
||||
"email": "Comportamiento del Correo",
|
||||
"composer": "Editor",
|
||||
"privacy": "Privacidad y Seguridad",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Buzón unificado",
|
||||
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas"
|
||||
"description": "Mostrar carpetas combinadas (Entrada, Enviados, etc.) de todas las cuentas conectadas",
|
||||
"include_group": {
|
||||
"label": "Incluir buzones de grupo",
|
||||
"description": "Incluir también los buzones compartidos o de grupo en la vista unificada."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Iconos de barra lateral a color",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Idioma y Región",
|
||||
"description": "Configure las preferencias de idioma y región",
|
||||
"title": "Idioma, Región y Hora",
|
||||
"description": "Idioma, formato de fecha, formato de hora y otras preferencias regionales",
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"description": "Elija su idioma preferido",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formato de Fecha",
|
||||
"description": "Cómo se deben mostrar las fechas",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personalizado"
|
||||
"description": "Cómo se muestran las fechas en la lista de correos",
|
||||
"smart": "Inteligente (según región)",
|
||||
"relative": "Relativo (hace 1 h, hace 2 d)",
|
||||
"full": "Fecha completa siempre",
|
||||
"preview_today": "Hoy:",
|
||||
"preview_this_week": "Esta semana:",
|
||||
"preview_older": "Más antiguo:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formato de Hora",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Continuer l'édition",
|
||||
"tabs": {
|
||||
"appearance": "Apparence",
|
||||
"language": "Langue et région",
|
||||
"language": "Langue, région et heure",
|
||||
"email": "Comportement email",
|
||||
"composer": "Compositeur",
|
||||
"privacy": "Confidentialité et sécurité",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Boîte aux lettres unifiée",
|
||||
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés"
|
||||
"description": "Afficher les dossiers combinés (Réception, Envoyés, etc.) de tous les comptes connectés",
|
||||
"include_group": {
|
||||
"label": "Inclure les boîtes de groupe",
|
||||
"description": "Inclure également les boîtes partagées ou de groupe dans la vue unifiée."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Icônes colorées dans la barre latérale",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Langue et région",
|
||||
"description": "Configurez vos préférences linguistiques et régionales",
|
||||
"title": "Langue, région et heure",
|
||||
"description": "Langue, format de date, format d'heure et autres préférences régionales",
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"description": "Choisissez votre langue préférée",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Format de date",
|
||||
"description": "Comment les dates doivent être affichées",
|
||||
"regional": "Régional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personnalisé"
|
||||
"description": "Comment les dates apparaissent dans la liste des e-mails",
|
||||
"smart": "Intelligent (adapté à la région)",
|
||||
"relative": "Relatif (il y a 1 h, il y a 2 j)",
|
||||
"full": "Toujours la date complète",
|
||||
"preview_today": "Aujourd'hui :",
|
||||
"preview_this_week": "Cette semaine :",
|
||||
"preview_older": "Plus ancien :"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Format d'heure",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Continua a modificare",
|
||||
"tabs": {
|
||||
"appearance": "Aspetto",
|
||||
"language": "Lingua e regione",
|
||||
"language": "Lingua, regione e ora",
|
||||
"email": "Comportamento email",
|
||||
"composer": "Editor",
|
||||
"privacy": "Privacy e sicurezza",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Casella di posta unificata",
|
||||
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati"
|
||||
"description": "Mostra le cartelle combinate (Posta in arrivo, Inviati, ecc.) di tutti gli account collegati",
|
||||
"include_group": {
|
||||
"label": "Includi le caselle di gruppo",
|
||||
"description": "Includi anche le caselle condivise o di gruppo nella vista unificata."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Icone colorate nella barra laterale",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Lingua e regione",
|
||||
"description": "Configura le preferenze di lingua e regionali",
|
||||
"title": "Lingua, regione e ora",
|
||||
"description": "Lingua, formato data, formato ora e altre preferenze regionali",
|
||||
"language": {
|
||||
"label": "Lingua",
|
||||
"description": "Scegli la tua lingua preferita",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formato data",
|
||||
"description": "Come devono essere visualizzate le date",
|
||||
"regional": "Regionale",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personalizzato"
|
||||
"description": "Come vengono visualizzate le date nell'elenco delle e-mail",
|
||||
"smart": "Intelligente (in base alla regione)",
|
||||
"relative": "Relativo (1 h fa, 2 g fa)",
|
||||
"full": "Sempre data completa",
|
||||
"preview_today": "Oggi:",
|
||||
"preview_this_week": "Questa settimana:",
|
||||
"preview_older": "Più vecchio:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formato ora",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "編集を続ける",
|
||||
"tabs": {
|
||||
"appearance": "外観",
|
||||
"language": "言語と地域",
|
||||
"language": "言語、地域、時刻",
|
||||
"email": "メール動作",
|
||||
"composer": "作成",
|
||||
"privacy": "プライバシーとセキュリティ",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "統合メールボックス",
|
||||
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示"
|
||||
"description": "接続されたすべてのアカウントの統合フォルダ(受信トレイ、送信済みなど)を表示",
|
||||
"include_group": {
|
||||
"label": "グループ受信トレイを含める",
|
||||
"description": "共有/グループ受信トレイも統合ビューに含めます。"
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "カラフルなサイドバーアイコン",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "言語と地域",
|
||||
"description": "言語と地域の設定を構成",
|
||||
"title": "言語、地域、時刻",
|
||||
"description": "言語、日付形式、時刻形式、その他の地域設定",
|
||||
"language": {
|
||||
"label": "言語",
|
||||
"description": "お好みの言語を選択",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "日付形式",
|
||||
"description": "日付の表示形式",
|
||||
"regional": "地域設定",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "カスタム"
|
||||
"description": "メール一覧での日付の表示方法",
|
||||
"smart": "スマート(地域に合わせる)",
|
||||
"relative": "相対表示(1時間前、2日前)",
|
||||
"full": "常に完全な日付",
|
||||
"preview_today": "今日:",
|
||||
"preview_this_week": "今週:",
|
||||
"preview_older": "それ以前:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "時刻形式",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "계속 수정하기",
|
||||
"tabs": {
|
||||
"appearance": "화면 설정",
|
||||
"language": "언어 및 지역",
|
||||
"language": "언어, 지역 및 시간",
|
||||
"email": "메일 동작",
|
||||
"composer": "메일 쓰기",
|
||||
"privacy": "개인정보 및 보안",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "통합 메일함",
|
||||
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다"
|
||||
"description": "연결된 모든 계정의 통합 폴더(받은편지함, 보낸편지함 등)를 표시합니다",
|
||||
"include_group": {
|
||||
"label": "그룹 받은편지함 포함",
|
||||
"description": "공유/그룹 받은편지함도 통합 보기에 포함합니다."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "컬러풀한 사이드바 아이콘",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "언어 및 지역",
|
||||
"description": "언어와 지역 형식을 설정해 주세요",
|
||||
"title": "언어, 지역 및 시간",
|
||||
"description": "언어, 날짜 형식, 시간 형식 및 기타 지역 환경설정",
|
||||
"language": {
|
||||
"label": "언어",
|
||||
"description": "사용할 언어를 선택해 주세요",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "날짜 형식",
|
||||
"description": "날짜가 표시되는 방식을 설정해요",
|
||||
"regional": "지역 설정",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "사용자 지정"
|
||||
"description": "이메일 목록에 날짜가 표시되는 방식",
|
||||
"smart": "스마트 (지역에 맞춤)",
|
||||
"relative": "상대 시간 (1시간 전, 2일 전)",
|
||||
"full": "항상 전체 날짜",
|
||||
"preview_today": "오늘:",
|
||||
"preview_this_week": "이번 주:",
|
||||
"preview_older": "이전:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "시간 형식",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Turpināt rediģēšanu",
|
||||
"tabs": {
|
||||
"appearance": "Izskats",
|
||||
"language": "Valoda un reģions",
|
||||
"language": "Valoda, reģions un laiks",
|
||||
"email": "Pasta darbība",
|
||||
"composer": "Redaktors",
|
||||
"privacy": "Privātums un drošība",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Apvienotā pastkaste",
|
||||
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem"
|
||||
"description": "Rādīt apvienotās mapes (Iesūtne, Nosūtītie u.c.) no visiem pievienotajiem kontiem",
|
||||
"include_group": {
|
||||
"label": "Iekļaut grupas pastkastes",
|
||||
"description": "Iekļaut apvienotajā skatā arī koplietotās/grupas pastkastes."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Krāsainas sānjoslas ikonas",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Valoda un reģions",
|
||||
"description": "Iestatiet valodas un reģionālās preferences",
|
||||
"title": "Valoda, reģions un laiks",
|
||||
"description": "Valoda, datuma formāts, laika formāts un citas reģionālās preferences",
|
||||
"language": {
|
||||
"label": "Valoda",
|
||||
"description": "Izvēlieties vēlamo valodu",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datuma formāts",
|
||||
"description": "Kā attēlot datumus",
|
||||
"regional": "Reģionālais",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Pielāgots"
|
||||
"description": "Kā datumi tiek attēloti e-pasta sarakstā",
|
||||
"smart": "Gudrs (atbilstoši reģionam)",
|
||||
"relative": "Relatīvs (pirms 1 st., pirms 2 d.)",
|
||||
"full": "Vienmēr pilns datums",
|
||||
"preview_today": "Šodien:",
|
||||
"preview_this_week": "Šajā nedēļā:",
|
||||
"preview_older": "Vecāks:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Laika formāts",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Doorgaan met bewerken",
|
||||
"tabs": {
|
||||
"appearance": "Uiterlijk",
|
||||
"language": "Taal & Regio",
|
||||
"language": "Taal, Regio & Tijd",
|
||||
"email": "E-mailgedrag",
|
||||
"composer": "Opstellen",
|
||||
"privacy": "Privacy & Beveiliging",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Gecombineerd postvak",
|
||||
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven"
|
||||
"description": "Gecombineerde mappen (Postvak IN, Verzonden, enz.) van alle verbonden accounts weergeven",
|
||||
"include_group": {
|
||||
"label": "Groepspostvakken meenemen",
|
||||
"description": "Gedeelde/groepspostvakken ook in de gecombineerde weergave opnemen."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Gekleurde zijbalkpictogrammen",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Taal & Regio",
|
||||
"description": "Configureer taal- en regiovoorkeuren",
|
||||
"title": "Taal, Regio & Tijd",
|
||||
"description": "Taal, datumnotatie, tijdnotatie en andere regionale voorkeuren",
|
||||
"language": {
|
||||
"label": "Taal",
|
||||
"description": "Kies je voorkeurstaal",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Datumnotatie",
|
||||
"description": "Hoe datums moeten worden weergegeven",
|
||||
"regional": "Regionaal",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Aangepast"
|
||||
"description": "Hoe datums worden weergegeven in de e-maillijst",
|
||||
"smart": "Slim (regionaal)",
|
||||
"relative": "Relatief (1 u geleden, 2 d geleden)",
|
||||
"full": "Altijd volledige datum",
|
||||
"preview_today": "Vandaag:",
|
||||
"preview_this_week": "Deze week:",
|
||||
"preview_older": "Ouder:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Tijdnotatie",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Kontynuuj edycję",
|
||||
"tabs": {
|
||||
"appearance": "Wygląd",
|
||||
"language": "Język i region",
|
||||
"language": "Język, region i czas",
|
||||
"email": "Zachowanie poczty e-mail",
|
||||
"composer": "Redagowanie",
|
||||
"privacy": "Prywatność i bezpieczeństwo",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Wspólna skrzynka",
|
||||
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont"
|
||||
"description": "Wyświetlaj połączone foldery (Odebrane, Wysłane itp.) ze wszystkich połączonych kont",
|
||||
"include_group": {
|
||||
"label": "Uwzględnij skrzynki grupowe",
|
||||
"description": "Dodaj również udostępnione/grupowe skrzynki do widoku wspólnego."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Kolorowe ikony paska bocznego",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Język i region",
|
||||
"description": "Skonfiguruj preferencje językowe i regionalne",
|
||||
"title": "Język, region i czas",
|
||||
"description": "Język, format daty, format godziny i inne preferencje regionalne",
|
||||
"language": {
|
||||
"label": "Język",
|
||||
"description": "Wybierz preferowany język",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Format daty",
|
||||
"description": "Jak mają być wyświetlane daty",
|
||||
"regional": "Regionalny",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Niestandardowy"
|
||||
"description": "Jak daty są wyświetlane na liście e-maili",
|
||||
"smart": "Inteligentny (regionalny)",
|
||||
"relative": "Względny (1 g temu, 2 d temu)",
|
||||
"full": "Zawsze pełna data",
|
||||
"preview_today": "Dzisiaj:",
|
||||
"preview_this_week": "W tym tygodniu:",
|
||||
"preview_older": "Starsze:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Format czasu",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Continuar editando",
|
||||
"tabs": {
|
||||
"appearance": "Aparência",
|
||||
"language": "Idioma e Região",
|
||||
"language": "Idioma, Região e Hora",
|
||||
"email": "Comportamento de E-mail",
|
||||
"composer": "Editor",
|
||||
"privacy": "Privacidade e Segurança",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Caixa de correio unificada",
|
||||
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas"
|
||||
"description": "Mostrar pastas combinadas (Entrada, Enviados, etc.) de todas as contas conectadas",
|
||||
"include_group": {
|
||||
"label": "Incluir caixas de grupo",
|
||||
"description": "Incluir também as caixas partilhadas ou de grupo na vista unificada."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Ícones coloridos na barra lateral",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Idioma e Região",
|
||||
"description": "Configure preferências de idioma e região",
|
||||
"title": "Idioma, Região e Hora",
|
||||
"description": "Idioma, formato de data, formato de hora e outras preferências regionais",
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"description": "Escolha seu idioma preferido",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Formato de Data",
|
||||
"description": "Como as datas devem ser exibidas",
|
||||
"regional": "Regional",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Personalizado"
|
||||
"description": "Como as datas são mostradas na lista de e-mails",
|
||||
"smart": "Inteligente (regional)",
|
||||
"relative": "Relativo (há 1 h, há 2 d)",
|
||||
"full": "Sempre data completa",
|
||||
"preview_today": "Hoje:",
|
||||
"preview_this_week": "Esta semana:",
|
||||
"preview_older": "Mais antigo:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Formato de Hora",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Продолжить редактирование",
|
||||
"tabs": {
|
||||
"appearance": "Внешний вид",
|
||||
"language": "Язык и регион",
|
||||
"language": "Язык, регион и время",
|
||||
"email": "Поведение почты",
|
||||
"composer": "Редактор",
|
||||
"privacy": "Конфиденциальность и безопасность",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Общий почтовый ящик",
|
||||
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов"
|
||||
"description": "Показывать объединённые папки (Входящие, Отправленные и др.) для всех подключённых аккаунтов",
|
||||
"include_group": {
|
||||
"label": "Включать групповые ящики",
|
||||
"description": "Также объединять общие/групповые ящики в едином представлении."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Цветные значки боковой панели",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Язык и регион",
|
||||
"description": "Настройте языковые и региональные предпочтения",
|
||||
"title": "Язык, регион и время",
|
||||
"description": "Язык, формат даты, формат времени и другие региональные настройки",
|
||||
"language": {
|
||||
"label": "Язык",
|
||||
"description": "Выберите предпочтительный язык",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Формат даты",
|
||||
"description": "Как отображать даты",
|
||||
"regional": "Региональный",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Пользовательский"
|
||||
"description": "Как отображаются даты в списке писем",
|
||||
"smart": "Умный (по региону)",
|
||||
"relative": "Относительный (1 ч назад, 2 д назад)",
|
||||
"full": "Всегда полная дата",
|
||||
"preview_today": "Сегодня:",
|
||||
"preview_this_week": "На этой неделе:",
|
||||
"preview_older": "Старее:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Формат времени",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Düzenlemeye devam et",
|
||||
"tabs": {
|
||||
"appearance": "Görünüm",
|
||||
"language": "Dil ve Bölge",
|
||||
"language": "Dil, Bölge ve Saat",
|
||||
"email": "E-posta Davranışı",
|
||||
"composer": "Yazıcı",
|
||||
"privacy": "Gizlilik ve Güvenlik",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Birleşik Posta Kutusu",
|
||||
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster"
|
||||
"description": "Bağlı tüm hesaplardaki birleşik klasörleri (Gelen Kutusu, Gönderilenler vb.) göster",
|
||||
"include_group": {
|
||||
"label": "Grup gelen kutularını dahil et",
|
||||
"description": "Paylaşılan/grup gelen kutularını da birleşik görünüme dahil et."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Renkli Kenar Çubuğu Simgeleri",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Dil ve Bölge",
|
||||
"description": "Dil ve bölgesel tercihleri yapılandırın",
|
||||
"title": "Dil, Bölge ve Saat",
|
||||
"description": "Dil, tarih biçimi, saat biçimi ve diğer bölgesel tercihler",
|
||||
"language": {
|
||||
"label": "Dil",
|
||||
"description": "Tercih ettiğiniz dili seçin",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Tarih Biçimi",
|
||||
"description": "Tarihlerin nasıl görüntüleneceği",
|
||||
"regional": "Bölgesel",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Özel"
|
||||
"description": "Tarihlerin e-posta listesinde nasıl gösterileceği",
|
||||
"smart": "Akıllı (bölgeye duyarlı)",
|
||||
"relative": "Göreceli (1 sa önce, 2 g önce)",
|
||||
"full": "Her zaman tam tarih",
|
||||
"preview_today": "Bugün:",
|
||||
"preview_this_week": "Bu hafta:",
|
||||
"preview_older": "Daha eski:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Saat Biçimi",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "Продовжуйте редагувати",
|
||||
"tabs": {
|
||||
"appearance": "Зовнішній вигляд",
|
||||
"language": "Мова та регіон",
|
||||
"language": "Мова, регіон і час",
|
||||
"email": "Поведінка електронної пошти",
|
||||
"composer": "Композитор",
|
||||
"privacy": "Конфіденційність і безпека",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "Спільна поштова скринька",
|
||||
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів"
|
||||
"description": "Показувати об'єднані папки (Вхідні, Надіслані тощо) для всіх підключених облікових записів",
|
||||
"include_group": {
|
||||
"label": "Включати групові скриньки",
|
||||
"description": "Також об'єднувати спільні/групові скриньки у спільному перегляді."
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "Кольорові значки бічної панелі",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "Мова та регіон",
|
||||
"description": "Налаштуйте мовні та регіональні параметри",
|
||||
"title": "Мова, регіон і час",
|
||||
"description": "Мова, формат дати, формат часу та інші регіональні налаштування",
|
||||
"language": {
|
||||
"label": "Мова",
|
||||
"description": "Виберіть бажану мову",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "Формат дати",
|
||||
"description": "Як мають відображатися дати",
|
||||
"regional": "Регіональний",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "Довільний"
|
||||
"description": "Як відображаються дати у списку листів",
|
||||
"smart": "Розумний (за регіоном)",
|
||||
"relative": "Відносний (1 год тому, 2 дн тому)",
|
||||
"full": "Завжди повна дата",
|
||||
"preview_today": "Сьогодні:",
|
||||
"preview_this_week": "Цього тижня:",
|
||||
"preview_older": "Старіше:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "Формат часу",
|
||||
|
||||
+15
-8
@@ -784,7 +784,7 @@
|
||||
"keep_editing": "继续编辑",
|
||||
"tabs": {
|
||||
"appearance": "外观",
|
||||
"language": "语言及地区",
|
||||
"language": "语言、地区与时间",
|
||||
"email": "邮件行为",
|
||||
"composer": "邮件撰写",
|
||||
"privacy": "隐私与安全",
|
||||
@@ -876,7 +876,11 @@
|
||||
},
|
||||
"unified_mailbox": {
|
||||
"label": "统一邮箱",
|
||||
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)"
|
||||
"description": "显示所有已连接账户的合并文件夹(收件箱、已发送等)",
|
||||
"include_group": {
|
||||
"label": "包含群组收件箱",
|
||||
"description": "在统一视图中也合并共享/群组收件箱。"
|
||||
}
|
||||
},
|
||||
"colorful_sidebar_icons": {
|
||||
"label": "彩色侧边栏图标",
|
||||
@@ -960,8 +964,8 @@
|
||||
}
|
||||
},
|
||||
"language_region": {
|
||||
"title": "语言及地区",
|
||||
"description": "配置语言和区域首选项",
|
||||
"title": "语言、地区与时间",
|
||||
"description": "语言、日期格式、时间格式以及其他区域设置",
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"description": "选择您的首选语言",
|
||||
@@ -970,10 +974,13 @@
|
||||
},
|
||||
"date_format": {
|
||||
"label": "日期格式",
|
||||
"description": "日期应如何显示",
|
||||
"regional": "区域格式",
|
||||
"iso": "ISO 8601",
|
||||
"custom": "自定义"
|
||||
"description": "电子邮件列表中日期的显示方式",
|
||||
"smart": "智能(按地区)",
|
||||
"relative": "相对时间(1 小时前、2 天前)",
|
||||
"full": "始终完整日期",
|
||||
"preview_today": "今天:",
|
||||
"preview_this_week": "本周:",
|
||||
"preview_older": "更早:"
|
||||
},
|
||||
"time_format": {
|
||||
"label": "时间格式",
|
||||
|
||||
@@ -97,8 +97,8 @@ export async function proxy(request: NextRequest) {
|
||||
const pluginFrameOrigins = await getEnabledPluginFrameOrigins();
|
||||
const frameSrc =
|
||||
pluginFrameOrigins.length > 0
|
||||
? `frame-src 'self' ${pluginFrameOrigins.join(" ")}`
|
||||
: `frame-src 'self'`;
|
||||
? `frame-src 'self' blob: ${pluginFrameOrigins.join(" ")}`
|
||||
: `frame-src 'self' blob:`;
|
||||
|
||||
const csp = [
|
||||
`default-src 'self'`,
|
||||
@@ -108,7 +108,7 @@ export async function proxy(request: NextRequest) {
|
||||
`font-src 'self'`,
|
||||
`connect-src ${connectSrc}`,
|
||||
frameSrc,
|
||||
`object-src 'none'`,
|
||||
`object-src 'self' blob:`,
|
||||
`base-uri 'self'`,
|
||||
`form-action 'self'`,
|
||||
`frame-ancestors ${frameAncestors}`,
|
||||
|
||||
+47
-8
@@ -147,11 +147,16 @@ interface EmailStore {
|
||||
* pass the active account's id explicitly (no `__default__` sentinel).
|
||||
* `destMailboxId` is the raw JMAP id on the destination server (not the
|
||||
* `accountId:mailboxId` namespace used for shared folders).
|
||||
* `destJmapAccountId` overrides the destination client's primary account
|
||||
* for the import — used when dropping into a delegated/shared mailbox that
|
||||
* is owned by a different JMAP account but accessed through the same
|
||||
* client (i.e. there is no separate connected client for the owner).
|
||||
*/
|
||||
crossAccountMoveEmails: (
|
||||
emailIdsBySource: Map<string, string[]>,
|
||||
destAccountId: string,
|
||||
destMailboxId: string,
|
||||
destJmapAccountId?: string,
|
||||
) => Promise<void>;
|
||||
searchEmails: (client: IJMAPClient, query: string) => Promise<void>;
|
||||
advancedSearch: (client: IJMAPClient) => Promise<void>;
|
||||
@@ -308,8 +313,16 @@ function resolveActionMailboxes(): Mailbox[] {
|
||||
* fresh mailbox list so the helpers can resolve the role mailbox per account.
|
||||
* Accounts whose mailbox fetch fails are skipped - the unified result will
|
||||
* surface that in its per-account error map.
|
||||
*
|
||||
* When `includeGroup` is true, also emits one synthetic entry per shared
|
||||
* owner account reachable through each logged-in client. The shared entries
|
||||
* are flagged with `isShared: true` so `lib/unified-mailbox.ts` routes JMAP
|
||||
* requests via `originalId` + owner accountId.
|
||||
*/
|
||||
async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
|
||||
export async function buildUnifiedAccountClients(
|
||||
opts: { includeGroup?: boolean } = {},
|
||||
): Promise<UnifiedAccountClient[]> {
|
||||
const { includeGroup = false } = opts;
|
||||
const authAccounts = useAccountStore.getState().accounts.filter((a) => a.isConnected);
|
||||
const allClients = useAuthStore.getState().getAllConnectedClients();
|
||||
const built: UnifiedAccountClient[] = [];
|
||||
@@ -317,8 +330,31 @@ async function buildUnifiedAccountClients(): Promise<UnifiedAccountClient[]> {
|
||||
const c = allClients.get(a.id);
|
||||
if (!c) continue;
|
||||
try {
|
||||
const mailboxes = await c.getMailboxes();
|
||||
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes });
|
||||
const mailboxes = includeGroup ? await c.getAllMailboxes() : await c.getMailboxes();
|
||||
const ownMailboxes = includeGroup
|
||||
? mailboxes.filter((m) => !m.isShared)
|
||||
: mailboxes;
|
||||
built.push({ accountId: a.id, accountLabel: a.label || a.email, client: c, mailboxes: ownMailboxes, isShared: false });
|
||||
|
||||
if (includeGroup) {
|
||||
const sharedByOwner = new Map<string, Mailbox[]>();
|
||||
for (const m of mailboxes) {
|
||||
if (!m.isShared || !m.accountId || m.accountId === a.id) continue;
|
||||
const list = sharedByOwner.get(m.accountId) ?? [];
|
||||
list.push(m);
|
||||
sharedByOwner.set(m.accountId, list);
|
||||
}
|
||||
for (const [ownerId, ownerMailboxes] of sharedByOwner) {
|
||||
const label = ownerMailboxes.find((m) => m.accountName)?.accountName || ownerId;
|
||||
built.push({
|
||||
accountId: ownerId,
|
||||
accountLabel: label,
|
||||
client: c,
|
||||
mailboxes: ownerMailboxes,
|
||||
isShared: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* skip account on mailbox fetch failure */
|
||||
}
|
||||
@@ -662,8 +698,9 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
set({ isLoadingMore: true, error: null });
|
||||
try {
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const position = emails.length;
|
||||
const built = await buildUnifiedAccountClients();
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const { searchFilters } = get();
|
||||
const hasFilters = !isFilterEmpty(searchFilters);
|
||||
const result = hasFilters
|
||||
@@ -1194,7 +1231,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId) => {
|
||||
crossAccountMoveEmails: async (emailIdsBySource, destAccountId, destMailboxId, destJmapAccountId) => {
|
||||
if (emailIdsBySource.size === 0) return;
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
@@ -1227,7 +1264,7 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
}
|
||||
const blob = await sourceClient.fetchBlob(full.blobId);
|
||||
const keywords: Record<string, boolean> = { ...(full.keywords ?? {}) };
|
||||
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords);
|
||||
await destClient.importRawEmail(blob, { [destMailboxId]: true }, keywords, destJmapAccountId);
|
||||
await sourceClient.deleteEmail(emailId);
|
||||
return emailId;
|
||||
}),
|
||||
@@ -1360,7 +1397,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
if (isUnifiedView && unifiedRole) {
|
||||
const built = await buildUnifiedAccountClients();
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const result = await searchUnifiedEmails(built, unifiedRole, query, emailsPerPage, 0);
|
||||
const externals = await emailHooks.onProvideSearchResults.transform([] as ExternalSearchResult[], { query, filters: get().searchFilters });
|
||||
set({
|
||||
@@ -1426,7 +1464,8 @@ export const useEmailStore = create<EmailStore>((set, get) => ({
|
||||
const emailsPerPage = useSettingsStore.getState().emailsPerPage;
|
||||
|
||||
if (isUnifiedView && unifiedRole) {
|
||||
const built = await buildUnifiedAccountClients();
|
||||
const includeGroup = useSettingsStore.getState().includeGroupInUnified;
|
||||
const built = await buildUnifiedAccountClients({ includeGroup });
|
||||
const result = await advancedSearchUnifiedEmails(
|
||||
built,
|
||||
unifiedRole,
|
||||
|
||||
@@ -353,6 +353,14 @@ export const useProTabStore = create<ProTabState>()(
|
||||
const target = state.tabs.find((t) => t.id === targetTabId);
|
||||
if (!dragged || !target) return;
|
||||
|
||||
// Mirror the moveTabToPane guard: never let a cross-pane reorder
|
||||
// empty the main pane, which would otherwise leave the layout with
|
||||
// a blank main pane next to a populated split pane.
|
||||
if (dragged.paneId === 'main' && target.paneId !== 'main') {
|
||||
const otherMainTabs = state.tabs.filter((t) => t.paneId === 'main' && t.id !== draggedId);
|
||||
if (otherMainTabs.length === 0) return;
|
||||
}
|
||||
|
||||
const next = state.tabs.filter((t) => t.id !== draggedId);
|
||||
const insertAt = next.findIndex((t) => t.id === targetTabId) + (edge === 'after' ? 1 : 0);
|
||||
const reassigned: ProTab = dragged.paneId === target.paneId
|
||||
@@ -504,6 +512,15 @@ export const useProTabStore = create<ProTabState>()(
|
||||
state.loadedTabIds = [HOME_TAB.id];
|
||||
return;
|
||||
}
|
||||
// Heal broken state where every tab ended up on the split pane:
|
||||
// collapse the split so the surviving tabs return to main, otherwise
|
||||
// the layout would render an empty main pane next to the split.
|
||||
if (!state.tabs.some((t) => t.paneId === 'main')) {
|
||||
state.tabs = state.tabs.map((t) => ({ ...t, paneId: 'main' as const }));
|
||||
state.activeSplitTabId = null;
|
||||
state.splitOrientation = null;
|
||||
state.focusedPaneId = 'main';
|
||||
}
|
||||
if (!state.tabs.some((t) => t.id === state.activeTabId && t.paneId === 'main')) {
|
||||
state.activeTabId = state.tabs.find((t) => t.paneId === 'main')?.id ?? HOME_TAB.id;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export type ListDensity = Density;
|
||||
export type DeleteAction = 'trash' | 'trash-and-read' | 'permanent';
|
||||
export type ReplyMode = 'reply' | 'replyAll';
|
||||
export type SignaturePosition = 'above_quote' | 'below_quote';
|
||||
export type DateFormat = 'regional' | 'iso' | 'custom';
|
||||
export type DateFormat = 'smart' | 'relative' | 'full';
|
||||
export type TimeFormat = '12h' | '24h';
|
||||
export type FirstDayOfWeek = 0 | 1; // 0 = Sunday, 1 = Monday
|
||||
export type ExternalContentPolicy = 'ask' | 'block' | 'allow';
|
||||
@@ -204,6 +204,7 @@ interface SettingsState {
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: boolean;
|
||||
includeGroupInUnified: boolean;
|
||||
|
||||
// Email Display
|
||||
disableThreading: boolean; // Show emails as individual messages instead of grouped by conversation
|
||||
@@ -301,7 +302,7 @@ const DEFAULT_SETTINGS = {
|
||||
animationsEnabled: true,
|
||||
|
||||
// Language & Region
|
||||
dateFormat: 'regional' as DateFormat,
|
||||
dateFormat: 'smart' as DateFormat,
|
||||
timeFormat: '24h' as TimeFormat,
|
||||
firstDayOfWeek: 1 as FirstDayOfWeek, // Monday
|
||||
|
||||
@@ -378,6 +379,7 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
// Unified Mailbox
|
||||
enableUnifiedMailbox: false,
|
||||
includeGroupInUnified: false,
|
||||
|
||||
// Email Display
|
||||
disableThreading: false,
|
||||
@@ -547,6 +549,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
// proInterface is intentionally omitted - it's a per-device choice
|
||||
// (see DEVICE_LOCAL_SETTING_KEYS) and must not be synced.
|
||||
enableUnifiedMailbox: state.enableUnifiedMailbox,
|
||||
includeGroupInUnified: state.includeGroupInUnified,
|
||||
senderFavicons: state.senderFavicons,
|
||||
showAvatarsInJunk: state.showAvatarsInJunk,
|
||||
colorfulSidebarIcons: state.colorfulSidebarIcons,
|
||||
@@ -765,7 +768,7 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: 'settings-storage',
|
||||
version: 3,
|
||||
version: 4,
|
||||
migrate: (persisted, version) => {
|
||||
const state = persisted as Record<string, unknown>;
|
||||
if (version < 2 && state.listDensity) {
|
||||
@@ -779,6 +782,12 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
state.protocolOpenMode = state.protocolMailtoOpenMode;
|
||||
}
|
||||
delete state.protocolMailtoOpenMode;
|
||||
// v4: `dateFormat` was repurposed from 'regional'|'iso'|'custom' to
|
||||
// 'smart'|'relative'|'full'. The old setting was never read anywhere,
|
||||
// so every persisted value maps to the new default.
|
||||
if (version < 4) {
|
||||
state.dateFormat = 'smart';
|
||||
}
|
||||
return state as unknown as SettingsState;
|
||||
},
|
||||
onRehydrateStorage: () => {
|
||||
|
||||
Reference in New Issue
Block a user