diff --git a/.env.example b/.env.example index 99da0cb0..5f21140e 100644 --- a/.env.example +++ b/.env.example @@ -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) # ============================================================================= diff --git a/Dockerfile b/Dockerfile index 8e3a3e68..2b7c21ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 0ef6ccd7..953195f4 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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() {
{appName} @@ -872,7 +872,7 @@ export default function LoginPage() {
{appName} diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index e9c283ef..24db868d 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -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 => { + return buildUnifiedAccountClients({ + includeGroup: useSettingsStore.getState().includeGroupInUnified, + }); }, []); - const populateUnifiedAccountMailboxes = useCallback( - async (list: UnifiedAccountClient[]): Promise => { - 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=` (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 diff --git a/app/(main)/admin/_tabs/_jmap-servers-section.tsx b/app/(main)/admin/_tabs/_jmap-servers-section.tsx index 69f6091d..638e9fee 100644 --- a/app/(main)/admin/_tabs/_jmap-servers-section.tsx +++ b/app/(main)/admin/_tabs/_jmap-servers-section.tsx @@ -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(() => 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); } diff --git a/app/(main)/admin/layout.tsx b/app/(main)/admin/layout.tsx index a792703a..19fd9020 100644 --- a/app/(main)/admin/layout.tsx +++ b/app/(main)/admin/layout.tsx @@ -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); diff --git a/app/(main)/admin/login/page.tsx b/app/(main)/admin/login/page.tsx index 9f57f3b3..018777d4 100644 --- a/app/(main)/admin/login/page.tsx +++ b/app/(main)/admin/login/page.tsx @@ -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(); diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index b98b064c..ac5c3b42 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -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 { formatDetection: { telephone: false, }, - icons: { icon: faviconUrl }, + icons: { icon: withBasePath(faviconUrl) }, }; } diff --git a/app/(main)/setup/page.tsx b/app/(main)/setup/page.tsx index 686bb202..c40664f5 100644 --- a/app/(main)/setup/page.tsx +++ b/app/(main)/setup/page.tsx @@ -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 ? ( - + ) : ( click or drop )} diff --git a/components/calendar/event-card.tsx b/components/calendar/event-card.tsx index f797f0ea..4886324c 100644 --- a/components/calendar/event-card.tsx +++ b/components/calendar/event-card.tsx @@ -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", diff --git a/components/calendar/event-detail-popover.tsx b/components/calendar/event-detail-popover.tsx index 78de3c28..1f0e4f67 100644 --- a/components/calendar/event-detail-popover.tsx +++ b/components/calendar/event-detail-popover.tsx @@ -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({
- - {formatEventDate(startDate)} - - {event.showWithoutTime ? ( - {t("events.all_day")} + {isMultiDay ? ( + event.showWithoutTime ? ( + <> +
+ {formatEventDate(startDate)} – +
+
+ {formatEventDate(displayEndDate)} +
+
{t("events.all_day")}
+ + ) : ( + <> +
+ {formatEventDate(startDate)} + + {formatTime(startDate)} + +
+
+ {formatEventDate(endDate)} + + {formatTime(endDate)} + +
+
+ ({formatDurationDisplay(durationMinutes)}) +
+ + ) ) : ( -
- {formatTime(startDate)} – {formatTime(endDate)} - ({formatDurationDisplay(durationMinutes)}) -
+ <> + + {formatEventDate(startDate)} + + {event.showWithoutTime ? ( + {t("events.all_day")} + ) : ( +
+ {formatTime(startDate)} – {formatTime(endDate)} + ({formatDurationDisplay(durationMinutes)}) +
+ )} + )}
diff --git a/components/calendar/event-modal.tsx b/components/calendar/event-modal.tsx index 1d815207..c0d7c87a 100644 --- a/components/calendar/event-modal.tsx +++ b/components/calendar/event-modal.tsx @@ -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({
-
- {formatEventDate(startD)} - {!event.showWithoutTime && ( - - {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} - - )} -
+ {(() => { + const displayEnd = getEventDisplayEndDate(event); + const multiDay = !isSameDay(startD, displayEnd); + if (multiDay && event.showWithoutTime) { + return ( +
+
{formatEventDate(startD)} –
+
{formatEventDate(displayEnd)}
+
+ ); + } + if (multiDay) { + return ( +
+
+ {formatEventDate(startD)} + {format(startD, timeDisplayFmt)} +
+
+ {formatEventDate(endD)} + {format(endD, timeDisplayFmt)} +
+
+ ); + } + return ( +
+ {formatEventDate(startD)} + {!event.showWithoutTime && ( + + {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} + + )} +
+ ); + })()} {event.description && (

{event.description}

@@ -742,17 +770,55 @@ export function EventModal({
- - {formatEventDate(startD)} - - {event.showWithoutTime ? ( - {t("events.all_day")} - ) : ( -
- {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} - ({formatDurationDisplay(durMin)}) -
- )} + {(() => { + const displayEnd = getEventDisplayEndDate(event); + const multiDay = !isSameDay(startD, displayEnd); + if (multiDay && event.showWithoutTime) { + return ( + <> +
{formatEventDate(startD)} –
+
{formatEventDate(displayEnd)}
+
{t("events.all_day")}
+ + ); + } + if (multiDay) { + return ( + <> +
+ {formatEventDate(startD)} + + {format(startD, timeDisplayFmt)} + +
+
+ {formatEventDate(endD)} + + {format(endD, timeDisplayFmt)} + +
+
+ ({formatDurationDisplay(durMin)}) +
+ + ); + } + return ( + <> + + {formatEventDate(startD)} + + {event.showWithoutTime ? ( + {t("events.all_day")} + ) : ( +
+ {format(startD, timeDisplayFmt)} – {format(endD, timeDisplayFmt)} + ({formatDurationDisplay(durMin)}) +
+ )} + + ); + })()}
diff --git a/components/email/email-list-item.tsx b/components/email/email-list-item.tsx index 2fdd4172..84813009 100644 --- a/components/email/email-list-item.tsx +++ b/components/email/email-list-item.tsx @@ -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?.( diff --git a/components/email/email-list.tsx b/components/email/email-list.tsx index 50f5aae5..80753f58 100644 --- a/components/email/email-list.tsx +++ b/components/email/email-list.tsx @@ -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) { diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index d93ab922..91c1f9b9 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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 (
@@ -5384,10 +5387,12 @@ export function EmailViewer({
diff --git a/components/email/thread-list-item.tsx b/components/email/thread-list-item.tsx index 68b17c40..11e84099 100644 --- a/components/email/thread-list-item.tsx +++ b/components/email/thread-list-item.tsx @@ -74,7 +74,9 @@ const SingleEmailItem = React.forwardRef( 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( 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 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 diff --git a/components/layout/navigation-rail.tsx b/components/layout/navigation-rail.tsx index 56a721d9..fc6649ff 100644 --- a/components/layout/navigation-rail.tsx +++ b/components/layout/navigation-rail.tsx @@ -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 ? (
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'); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index cb21f7e1..00c8bfb3 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -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; @@ -58,7 +59,8 @@ export function PWAInstallPrompt() { return null; } - const logoSrc = appLogoLightUrl || faviconUrl; + const logoSrc = withBasePath(appLogoLightUrl || faviconUrl); + const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl); return (
@@ -75,7 +77,7 @@ export function PWAInstallPrompt() { )} {logoSrc && ( {appName} diff --git a/components/settings/calendar-settings.tsx b/components/settings/calendar-settings.tsx index b8e655f8..4154f17b 100644 --- a/components/settings/calendar-settings.tsx +++ b/components/settings/calendar-settings.tsx @@ -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() { /> - - 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') }, + ]} + /> +
+
+ {t('date_format.preview_today')} + {preview.today} +
+
+ {t('date_format.preview_this_week')} + {preview.thisWeek} +
+
+ {t('date_format.preview_older')} + {preview.older} +
+
+
+ + + + updateSetting('timeFormat', value as TimeFormat)} + options={[ + { value: '12h', label: t('time_format.12h') }, + { value: '24h', label: t('time_format.24h') }, + ]} + /> + + + +