feat: deep links for mail, calendar, contacts, files, settings and improve screen reader support

#733 #722 #721  #720  #719
This commit is contained in:
Linus Rath
2026-08-06 16:47:20 +02:00
parent b3f5f51efa
commit e2da05c130
68 changed files with 6248 additions and 3895 deletions
+1
View File
@@ -99,6 +99,7 @@
- Drag and drop to organize mail and assign tags
- A guided tour for first-time users
- Right-click menus, and toasts that offer an undo
- Every screen has a permalink. `/mail/message/<id>`, `/mail/thread/<id>`, `/mail/folder/inbox`, `/calendar/day/2026-08-06`, `/calendar/event/<id>`, `/contacts/<id>`, `/files/<folder>`, and `/settings/<tab>` all open directly, the address bar follows what you are looking at, and "Copy link" is in the message, conversation, and event menus. Dashboards and launchers can link straight to a message; browser back and forward walk the mail UI.
- Toolbar position, favicon, and login branding are configurable
- Sidebar apps pin and reorder by drag
- Settings sync between devices, encrypted
+6 -3
View File
@@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { apiFetch, getPathPrefix, toRouterPath } from "@/lib/browser-navigation";
import { buildSettingsPath } from "@/lib/deep-links";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
@@ -56,11 +57,13 @@ function OAuthCallbackInner() {
}
try {
sessionStorage.setItem("pair_reauth_done", "1");
// Land back on the Security tab (readPersistedTab reads this key).
sessionStorage.setItem("settings-deep-link-tab", "security");
} catch { /* ignore */ }
// Land back on the Security tab. The tab is part of the URL now
// (#733), so the redirect target says where it is going.
const prefix = getPathPrefix(params.locale as string);
router.push(toRouterPath(`${prefix}/${params.locale}/settings`));
router.push(toRouterPath(
`${prefix}/${params.locale}${buildSettingsPath("security")}`,
));
} catch {
setError("token_exchange_failed");
}
@@ -0,0 +1,15 @@
import { CalendarApp } from "@/components/calendar/calendar-app";
// Calendar permalinks (#733):
// /calendar the stored view, today
// /calendar/<view>/<YYYY-MM-DD> a view anchored on a date
// /calendar/event/<eventId> one event, with ?calendar=<id> to
// disambiguate across accounts
export default async function CalendarRoute({
params,
}: {
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <CalendarApp linkSegments={segments ?? []} />;
}
@@ -0,0 +1,13 @@
import { ContactsApp } from "@/components/contacts/contacts-app";
// Contact permalinks (#733): /contacts/<contactId>[/edit], plus /contacts/new
// for the "add this sender" flow. The older query form (?contactId=, ?addEmail=)
// still works - see parseContactsPath.
export default async function ContactsRoute({
params,
}: {
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <ContactsApp linkSegments={segments ?? []} />;
}
@@ -0,0 +1,12 @@
import { FilesApp } from "@/components/files/files-app";
// Files permalinks (#733): /files/<folder>/<subfolder>, one path segment per
// level, with ?preview=<name> to open a file straight into the preview modal.
export default async function FilesRoute({
params,
}: {
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <FilesApp linkSegments={segments ?? []} />;
}
+22 -8
View File
@@ -11,6 +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 { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { apiFetch, getPathPrefix, toRouterPath, 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";
@@ -183,6 +184,13 @@ export default function LoginPage() {
const totpInputRef = useRef<HTMLInputElement>(null);
const prevError = useRef<string | null>(null);
const themeMenuRef = useRef<HTMLDivElement>(null);
const themeButtonRef = useRef<HTMLButtonElement>(null);
const closeThemeMenu = useCallback(() => setShowThemeMenu(false), []);
const { menuRef: themeListRef, onKeyDown: onThemeMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open: showThemeMenu,
onClose: closeThemeMenu,
triggerRef: themeButtonRef,
});
// Captured by handleSubmit when in mobile handoff mode; consumed by the
// isAuthenticated effect to build the deep-link fragment.
const mobileHandoffPayloadRef = useRef<{ server_url: string; username: string; password: string } | null>(null);
@@ -689,6 +697,7 @@ export default function LoginPage() {
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
<button
type="button"
ref={themeButtonRef}
onClick={() => setShowThemeMenu(!showThemeMenu)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
@@ -698,7 +707,7 @@ export default function LoginPage() {
)}
aria-label={`Theme: ${currentThemeOption.label}`}
aria-expanded={showThemeMenu}
aria-haspopup="listbox"
aria-haspopup="menu"
>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
@@ -706,8 +715,10 @@ export default function LoginPage() {
{showThemeMenu && (
<div
ref={themeListRef}
onKeyDown={onThemeMenuKeyDown}
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="listbox"
role="menu"
aria-label="Theme selection"
>
{THEME_OPTIONS.map((option) => {
@@ -717,8 +728,8 @@ export default function LoginPage() {
<button
key={option.value}
type="button"
role="option"
aria-selected={isActive}
role="menuitemradio"
aria-checked={isActive}
onClick={() => handleThemeSelect(option.value)}
className={cn(
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
@@ -838,6 +849,7 @@ export default function LoginPage() {
<div className="absolute top-5 right-5" ref={themeMenuRef} suppressHydrationWarning>
<button
type="button"
ref={themeButtonRef}
onClick={() => setShowThemeMenu(!showThemeMenu)}
className={cn(
"flex items-center gap-2 px-3 py-2 rounded-xl border text-sm transition-all duration-200",
@@ -847,7 +859,7 @@ export default function LoginPage() {
)}
aria-label={`Theme: ${currentThemeOption.label}`}
aria-expanded={showThemeMenu}
aria-haspopup="listbox"
aria-haspopup="menu"
>
<CurrentThemeIcon className="w-4 h-4" />
<span className="hidden sm:inline" suppressHydrationWarning>{currentThemeOption.label}</span>
@@ -855,8 +867,10 @@ export default function LoginPage() {
{showThemeMenu && (
<div
ref={themeListRef}
onKeyDown={onThemeMenuKeyDown}
className="absolute right-0 top-full mt-2 w-40 rounded-xl border border-border bg-background shadow-lg overflow-hidden animate-fade-in z-50"
role="listbox"
role="menu"
aria-label="Theme selection"
>
{THEME_OPTIONS.map((option) => {
@@ -866,8 +880,8 @@ export default function LoginPage() {
<button
key={option.value}
type="button"
role="option"
aria-selected={isActive}
role="menuitemradio"
aria-checked={isActive}
onClick={() => handleThemeSelect(option.value)}
className={cn(
"w-full flex items-center gap-3 px-3.5 py-2.5 text-sm transition-colors",
@@ -0,0 +1,20 @@
import { MailApp } from "@/components/mail/mail-app";
// Permalinks into the mail client (#733):
// /mail the list
// /mail/folder/<role|id> a mailbox
// /mail/message/<emailId> one message
// /mail/thread/<threadId> a conversation
//
// Every one of them renders the same client shell as "/" - the segments are
// an intent the shell applies once its JMAP session is up, not a different
// page. Unknown segments are ignored and the shell falls back to the list, so
// stale links degrade instead of 404ing.
export default async function MailDeepLinkRoute({
params,
}: {
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <MailApp linkSegments={segments ?? []} />;
}
File diff suppressed because it is too large Load Diff
+13 -10
View File
@@ -18,20 +18,23 @@ import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/
import { cn } from "@/lib/utils";
import { getPathPrefix } from "@/lib/browser-navigation";
import MailPage from "@/app/(main)/[locale]/page";
import CalendarPage from "@/app/(main)/[locale]/calendar/page";
import ContactsPage from "@/app/(main)/[locale]/contacts/page";
import FilesPage from "@/app/(main)/[locale]/files/page";
import SettingsPage from "@/app/(main)/[locale]/settings/page";
import { MailApp } from "@/components/mail/mail-app";
import { CalendarApp } from "@/components/calendar/calendar-app";
import { ContactsApp } from "@/components/contacts/contacts-app";
import { FilesApp } from "@/components/files/files-app";
import { SettingsApp } from "@/components/settings/settings-app";
import { ProComposeTabBody } from "@/components/pro/pro-compose-tab-body";
import { ProEmailTabBody } from "@/components/pro/pro-email-tab-body";
// Each surface renders the same component the standard routes do. Deep links
// reach them through the handoff ProInterfaceRedirect parks (#733), not as
// props - Pro tabs have no route of their own.
const APP_TAB_COMPONENTS: Partial<Record<ProTabKind, ComponentType>> = {
mail: MailPage,
calendar: CalendarPage,
contacts: ContactsPage,
files: FilesPage,
settings: SettingsPage,
mail: MailApp,
calendar: CalendarApp,
contacts: ContactsApp,
files: FilesApp,
settings: SettingsApp,
};
type DropTarget = 'left' | 'right' | null;
@@ -0,0 +1,12 @@
import { SettingsApp } from "@/components/settings/settings-app";
// Settings permalinks (#733): /settings/<tabId>. Without a tab the page falls
// back to the last tab the user had open, as before.
export default async function SettingsRoute({
params,
}: {
params: Promise<{ segments?: string[] }>;
}) {
const { segments } = await params;
return <SettingsApp linkSegments={segments ?? []} />;
}
+2 -1
View File
@@ -349,7 +349,8 @@ function Toggle({ label, description, configKey, value, source, onChange, onReve
{description && <p className="text-xs text-muted-foreground mt-0.5">{description}</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<button onClick={() => onChange(configKey, !value)}
<button type="button" role="switch" aria-checked={value} aria-label={label}
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${value ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
+6 -3
View File
@@ -316,7 +316,8 @@ export function PluginsTab() {
<span className="text-sm text-foreground">Plugins Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow the plugin system to load and run plugins for users</p>
</div>
<button onClick={togglePluginsEnabled}
<button type="button" role="switch" aria-checked={pluginsEnabled} aria-label="Plugins Enabled"
onClick={togglePluginsEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
@@ -327,7 +328,8 @@ export function PluginsTab() {
<span className="text-sm text-foreground">User Plugin Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload plugin ZIP files in Settings</p>
</div>
<button onClick={togglePluginsUploadEnabled}
<button type="button" role="switch" aria-checked={pluginsUploadEnabled} aria-label="User Plugin Uploads"
onClick={togglePluginsUploadEnabled}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${pluginsUploadEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${pluginsUploadEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
@@ -338,7 +340,8 @@ export function PluginsTab() {
<span className="text-sm text-foreground">Require Admin Approval</span>
<p className="text-xs text-muted-foreground mt-0.5">User-uploaded plugins must be approved by an admin before they can be enabled</p>
</div>
<button onClick={toggleRequirePluginApproval}
<button type="button" role="switch" aria-checked={requirePluginApproval} aria-label="Require Admin Approval"
onClick={toggleRequirePluginApproval}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${requirePluginApproval ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${requirePluginApproval ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
+2 -1
View File
@@ -192,7 +192,8 @@ export function PolicyTab() {
<span className="text-sm text-foreground">{label}</span>
<p className="text-xs text-muted-foreground mt-0.5">{description}</p>
</div>
<button onClick={() => toggleFeature(key)}
<button type="button" role="switch" aria-checked={enabled} aria-label={label}
onClick={() => toggleFeature(key)}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${enabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${enabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
+4
View File
@@ -230,6 +230,10 @@ function ToggleSetting({ label, description, configKey, value, source, onChange,
</div>
<div className="flex items-center gap-2 shrink-0">
<button
type="button"
role="switch"
aria-checked={value}
aria-label={label}
onClick={() => onChange(configKey, !value)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${value ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}
>
+8 -4
View File
@@ -358,7 +358,8 @@ export function ThemesTab() {
<span className="text-sm text-foreground">Themes Enabled</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to select and apply themes</p>
</div>
<button onClick={toggleThemesEnabled}
<button type="button" role="switch" aria-checked={themesEnabled} aria-label="Themes Enabled"
onClick={toggleThemesEnabled}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${themesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${themesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
@@ -369,7 +370,8 @@ export function ThemesTab() {
<span className="text-sm text-foreground">User Theme Uploads</span>
<p className="text-xs text-muted-foreground mt-0.5">Allow users to upload their own theme files</p>
</div>
<button onClick={toggleUserThemeUploads}
<button type="button" role="switch" aria-checked={userThemesEnabled} aria-label="User Theme Uploads"
onClick={toggleUserThemeUploads}
className={`shrink-0 relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${userThemesEnabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${userThemesEnabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
@@ -440,7 +442,8 @@ export function ThemesTab() {
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleBuiltinTheme(theme.id)}
<button type="button" role="switch" aria-checked={!disabled} aria-label={theme.name}
onClick={() => toggleBuiltinTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
@@ -459,7 +462,8 @@ export function ThemesTab() {
return (
<div key={theme.id} className="flex items-center justify-between gap-4">
<span className="text-sm text-foreground">{theme.name}</span>
<button onClick={() => toggleAdminTheme(theme.id)}
<button type="button" role="switch" aria-checked={!disabled} aria-label={theme.name}
onClick={() => toggleAdminTheme(theme.id)}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${!disabled ? 'bg-primary' : 'bg-muted-foreground/25 dark:bg-muted-foreground/50'}`}>
<span className={`inline-block h-3.5 w-3.5 transform rounded-full bg-background shadow transition-transform ${!disabled ? 'translate-x-[18px]' : 'translate-x-[3px]'}`} />
</button>
@@ -11,7 +11,7 @@ import {
} from "date-fns";
import { useCalendarStore } from "@/stores/calendar-store";
import { isCalendarViewMode } from "@/stores/calendar-store";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
import { useIdentityStore } from "@/stores/identity-store";
@@ -65,6 +65,10 @@ import { sharedCalendarColorKey, pickUnusedCalendarColor } from "@/lib/shared-ca
import { debug } from "@/lib/debug";
import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session";
import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal";
import { appPath, buildCalendarPath, parseCalendarPath } from "@/lib/deep-links";
import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
type PendingScopeAction =
| { type: "edit"; event: CalendarEvent; updates: Partial<CalendarEvent>; sendScheduling?: boolean }
@@ -74,10 +78,16 @@ function isRecurringEvent(event: CalendarEvent): boolean {
return (event.recurrenceRules?.length ?? 0) > 0 || event.recurrenceId != null;
}
export default function CalendarPage() {
export interface CalendarAppProps {
/** Path segments after `/calendar` (`['day', '2026-08-06']`). */
linkSegments?: string[];
}
export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
const router = useRouter();
const t = useTranslations("calendar");
const tWebcalAction = useTranslations("calendar.webcal_action");
const tDeepLink = useTranslations("deep_link");
const isMobile = useIsMobile();
const isDesktop = useIsDesktop();
const isEmbedded = useIsEmbedded();
@@ -179,7 +189,7 @@ export default function CalendarPage() {
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
saveRedirectAfterLogin();
redirectToLogin();
} else if (client && !calendarEnabled) {
// Calendar disabled by admin policy - send the user back to mail.
@@ -563,6 +573,58 @@ export default function CalendarPage() {
}, 300);
}, []);
// ---- Deep links (#733) ---------------------------------------------------
// `/calendar/<view>/<date>` moves the grid; `/calendar/event/<id>` opens the
// event's sidebar and parks the grid on the day it starts. Applied once the
// JMAP session is up, then the URL becomes an output of the view (below).
const deepLinkHandledRef = useRef(false);
useEffect(() => {
if (deepLinkHandledRef.current) return;
if (!isAuthenticated || !client) return;
const segments = linkSegments ?? consumePendingDeepLink('calendar') ?? [];
const link = parseCalendarPath(segments, new URLSearchParams(window.location.search));
deepLinkHandledRef.current = true;
if (!link) return;
if (link.kind === 'view') {
setViewMode(link.view);
if (link.date) setSelectedDate(link.date);
return;
}
void (async () => {
try {
const event = await client.getCalendarEvent(link.id, link.accountId);
if (!event) {
toast.error(tDeepLink('event_not_found'));
return;
}
const start = getEventStartDate(event);
if (start) setSelectedDate(start);
openEditModal(event);
} catch (err) {
debug.error('Failed to open calendar deep link:', err);
toast.error(tDeepLink('event_not_found'));
}
})();
}, [isAuthenticated, client, linkSegments, setViewMode, setSelectedDate, openEditModal, tDeepLink]);
// The permalink for what the calendar is currently showing. Suppressed in
// the Pro shell, where /pro owns the address bar.
const proInterfaceActive = useProInterfaceActive();
const eventLinkId = showEventModal && editEvent ? editEvent.id : null;
useDeepLinkUrl(
isEmbedded || proInterfaceActive
? null
: appPath(buildCalendarPath({
view: normalizedViewMode,
date: selectedDate,
eventId: eventLinkId,
accountId: eventLinkId ? editEvent?.accountId : null,
})),
);
const handleEditFromDetail = useCallback(() => {
if (detailEvent) {
const ev = detailEvent;
@@ -12,9 +12,12 @@ import {
Download,
ClipboardCopy,
Link as LinkIcon,
CalendarArrowUp as CalendarLinkIcon,
Trash2,
} from "lucide-react";
import type { CalendarEvent } from "@/lib/jmap/types";
import { buildCalendarPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
interface Position {
x: number;
@@ -49,6 +52,8 @@ export function EventContextMenu({
onDelete,
}: EventContextMenuProps) {
const t = useTranslations("calendar");
const tDeepLink = useTranslations("deep_link");
const copyLink = useCopyLink();
const handle = (fn: () => void) => () => {
fn();
@@ -81,6 +86,20 @@ export function EventContextMenu({
onClick={handle(onCopyMeetingLink)}
/>
)}
{/* Permalink into Bulwark (#733) - distinct from the meeting URL above,
which points at whatever conferencing tool the organiser used. */}
<ContextMenuItem
icon={CalendarLinkIcon}
label={tDeepLink("copy_event")}
onClick={handle(() => {
void copyLink(buildCalendarPath({
view: "month",
date: null,
eventId: event.id,
accountId: event.accountId,
}));
})}
/>
<ContextMenuSeparator />
<ContextMenuItem
icon={Trash2}
@@ -21,7 +21,7 @@ import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { savePendingMailto } from "@/lib/protocol-handlers/session";
import { formatRecipient, formatRecipientEntry, type Recipient } from "@/lib/email-composer-utils";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useEmailStore } from "@/stores/email-store";
import { usePolicyStore } from "@/stores/policy-store";
import { toast } from "@/stores/toast-store";
@@ -37,6 +37,10 @@ import { useIsDesktop, useIsMobile } from "@/hooks/use-media-query";
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
import type { ContactCard, AddressBook, AddressBookRights } from "@/lib/jmap/types";
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
import { appPath, buildContactsPath, parseContactsPath } from "@/lib/deep-links";
import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
type View =
| "list"
@@ -48,7 +52,12 @@ type View =
| "group-edit"
| "bulk-add-to-group";
export default function ContactsPage() {
export interface ContactsAppProps {
/** Path segments after `/contacts` (`['<contactId>', 'edit']`). */
linkSegments?: string[];
}
export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
const t = useTranslations("contacts");
const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled'));
const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore();
@@ -150,7 +159,7 @@ export default function ContactsPage() {
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
saveRedirectAfterLogin();
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
@@ -168,29 +177,41 @@ export default function ContactsPage() {
}
}, [client, supportsSync, fetchContacts, isEmbedded]);
// Consume one-shot URL params (set by the mobile recipient popover when no
// sidebar is available) and strip them so a refresh doesn't replay the
// intent. `from=email` flips the mobile back button to `router.back()`.
// Apply the deep link once (#733): `/contacts/<id>[/edit]` and `/contacts/new`,
// plus the older one-shot query form the mobile recipient popover emits
// (`?contactId=`, `?addEmail=`). `from=email` flips the mobile back button to
// `router.back()`. After this the URL is an output of the view, not an input.
useEffect(() => {
if (intentAppliedRef.current) return;
const contactId = searchParams.get('contactId');
const addEmail = searchParams.get('addEmail');
const addName = searchParams.get('addName');
const from = searchParams.get('from');
const viewParam = searchParams.get('view');
if (!contactId && !addEmail && !from) return;
const segments = linkSegments ?? consumePendingDeepLink('contacts') ?? [];
const link = parseContactsPath(
segments,
new URLSearchParams(searchParams.toString()),
);
intentAppliedRef.current = true;
if (from === 'email') setReturnToEmail(true);
if (contactId) {
setSelectedContact(contactId);
setView(viewParam === 'edit' ? 'edit' : 'detail');
} else if (addEmail) {
setCreatePrefill({ email: addEmail, name: addName ?? undefined });
if (!link) return;
if (link.fromEmail) setReturnToEmail(true);
if (link.kind === 'contact') {
setSelectedContact(link.id);
setView(link.edit ? 'edit' : 'detail');
} else {
setCreatePrefill({ email: link.email, name: link.name });
setSelectedContact(null);
setView('create');
}
router.replace('/contacts');
}, [searchParams, router, setSelectedContact]);
}, [searchParams, linkSegments, setSelectedContact]);
// The permalink for the contact on screen. Suppressed inside the Pro shell,
// where /pro owns the address bar.
const proInterfaceActive = useProInterfaceActive();
useDeepLinkUrl(
isEmbedded || proInterfaceActive
? null
: appPath(buildContactsPath({
contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
editing: view === 'edit',
})),
);
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
// and refresh contacts via JMAP instead of reloading the page.
+30
View File
@@ -33,7 +33,11 @@ import {
CalendarClock,
XCircle,
Paperclip,
Link as LinkIcon,
MessagesSquare,
} from "lucide-react";
import { buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
import { buildMailboxTree, MailboxNode } from "@/lib/utils";
import { localizeMailboxName } from "@/lib/mailbox-label";
import { getEmailTagIds } from "@/lib/thread-utils";
@@ -138,6 +142,8 @@ export function EmailContextMenu({
const t = useTranslations("context_menu");
const tSidebar = useTranslations("sidebar");
const tEmailViewer = useTranslations("email_viewer");
const tDeepLink = useTranslations("deep_link");
const copyLink = useCopyLink();
const isUnread = !email.keywords?.$seen;
const isStarred = email.keywords?.$flagged;
const isPinned = email.keywords?.['$pinned'] === true;
@@ -267,6 +273,30 @@ export function EmailContextMenu({
</>
)}
{/* Permalinks (#733). The conversation entry only appears when the
message actually belongs to a thread worth linking to. */}
{!showBatchActions && (
<>
<ContextMenuItem
icon={LinkIcon}
label={tDeepLink("copy_message")}
onClick={() => handleAction(() => {
void copyLink(buildMailPath({ mailboxId: null, emailId: email.id, threadId: null }));
})}
/>
{email.threadId && (
<ContextMenuItem
icon={MessagesSquare}
label={tDeepLink("copy_conversation")}
onClick={() => handleAction(() => {
void copyLink(buildMailPath({ mailboxId: null, emailId: null, threadId: email.threadId! }));
})}
/>
)}
<ContextMenuSeparator />
</>
)}
{/* Archive */}
<ContextMenuItem
icon={Archive}
+137 -12
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback } from "react";
import { useState, useEffect, useLayoutEffect, useMemo, useRef, useCallback, useId } from "react";
import DOMPurify from "dompurify";
import { Email, ContactCard, Mailbox } from "@/lib/jmap/types";
import { emailExportFilename, attachmentDownloadFilename, attachmentsBundleFilename, DEFAULT_EMAIL_TEMPLATE, DEFAULT_ATTACHMENT_TEMPLATE } from "@/lib/download-filename";
@@ -9,6 +9,8 @@ import { EMAIL_IFRAME_SANITIZE_CONFIG, applyNewTabToAnchor, blockExternalResourc
import { hasMeaningfulHtmlBody } from "@/lib/signature-utils";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { withBasePath } from "@/lib/browser-navigation";
import { buildContactsPath, buildMailPath } from "@/lib/deep-links";
import { useCopyLink } from "@/hooks/use-copy-link";
import { Button } from "@/components/ui/button";
import { Avatar } from "@/components/ui/avatar";
import { formatFileSize, cn, buildMailboxTree, MailboxNode, formatDateTime, generateUUID } from "@/lib/utils";
@@ -75,6 +77,7 @@ import {
PlayCircle,
PenSquare,
CalendarClock,
Link as LinkIcon,
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useRouter } from "@/i18n/navigation";
@@ -95,6 +98,7 @@ import { ReadReceiptBanner } from "./read-receipt-banner";
import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities";
import { useTour } from "@/components/tour/tour-provider";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { findCalendarAttachment, isCalendarMimeType } from "@/lib/calendar-invitation";
import { RecipientPopover } from "./recipient-popover";
import { MailtoLink } from "@/components/ui/mailto-link";
@@ -649,6 +653,8 @@ export function EmailViewer({
const tCommon = useTranslations('common');
const tFiles = useTranslations('files');
const tDemoWelcome = useTranslations('demo_welcome');
const tDeepLink = useTranslations('deep_link');
const copyLink = useCopyLink();
const tWelcome = useTranslations('welcome');
const externalContentPolicy = useSettingsStore((state) => state.externalContentPolicy);
const messageSpacing = useSettingsStore((state) => state.messageSpacing);
@@ -805,7 +811,31 @@ export function EmailViewer({
const moreMenuRef = useRef<HTMLDivElement>(null);
const tagMenuRef = useRef<HTMLDivElement>(null);
const moveMenuRef = useRef<HTMLDivElement>(null);
const moreButtonRef = useRef<HTMLButtonElement>(null);
const moveButtonRef = useRef<HTMLButtonElement>(null);
// Pro can mount two reading panes side by side, so the menu id has to be
// per-instance for aria-controls to point at the right one.
const moreMenuId = useId();
const toolbarRef = useRef<HTMLDivElement>(null);
const closeMoreMenu = useCallback(() => { setMoreMenuOpen(false); setMoreMenuSub(null); }, []);
const closeMoveMenu = useCallback(() => setMoveMenuOpen(false), []);
// Both menus render away from their trigger (a dropdown on desktop, an
// off-canvas panel on mobile), so focus has to be driven explicitly (#720).
const { menuRef: moreMenuListRef, onKeyDown: onMoreMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open: moreMenuOpen && !isMobile,
onClose: closeMoreMenu,
triggerRef: moreButtonRef,
});
const { menuRef: mobileMoreRef, onKeyDown: onMobileMoreKeyDown } = useMenuNavigation<HTMLDivElement>({
open: moreMenuOpen && isMobile,
onClose: closeMoreMenu,
triggerRef: moreButtonRef,
});
const { menuRef: moveMenuListRef, onKeyDown: onMoveMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open: moveMenuOpen,
onClose: closeMoveMenu,
triggerRef: moveButtonRef,
});
const [hiddenPriorities, setHiddenPriorities] = useState<Set<number>>(new Set());
const currentTagIds = getEmailTagIds(email?.keywords);
const sortedTagIds = sortTagIds(currentTagIds);
@@ -1041,15 +1071,18 @@ export function EmailViewer({
const recipientName = allRecipients.find(
(r) => r.email.toLowerCase() === recipientEmail.toLowerCase()
)?.name;
const params = new URLSearchParams();
// Canonical contact permalink (#733); `from=email` keeps the mobile back
// button pointing at the message the user came from.
const params = new URLSearchParams({ from: 'email' });
let path: string;
if (contact) {
params.set('contactId', contact.id);
path = buildContactsPath({ contactId: contact.id });
} else {
params.set('addEmail', recipientEmail);
if (recipientName) params.set('addName', recipientName);
path = '/contacts/new';
params.set('email', recipientEmail);
if (recipientName) params.set('name', recipientName);
}
params.set('from', 'email');
router.push(`/contacts?${params.toString()}`);
router.push(`${path}?${params.toString()}`);
return;
}
setContactSidebarEmail(recipientEmail);
@@ -2935,17 +2968,27 @@ export function EmailViewer({
{moveTree.length > 0 && onMoveToMailbox && (
<div ref={moveMenuRef} data-overflow-item data-overflow-priority="5" className="relative">
<Button
ref={moveButtonRef}
variant="ghost"
size="sm"
onClick={() => { setMoveMenuOpen(!moveMenuOpen); setMoreMenuOpen(false); setTagMenuOpen(false); }}
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:gap-1.5 sm:py-0"
title={t('move_to')}
aria-label={t('move_to')}
aria-haspopup="menu"
aria-expanded={moveMenuOpen}
>
<FolderInput className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('move')}</span>}
</Button>
{moveMenuOpen && (
<div className="absolute end-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10">
<div
ref={moveMenuListRef}
onKeyDown={onMoveMenuKeyDown}
role="menu"
aria-label={t('move_to')}
className="absolute end-0 top-full mt-1 py-1 w-48 max-h-72 overflow-y-auto bg-background rounded-lg shadow-lg border border-border z-10"
>
{(() => {
const renderNodes = (nodes: MailboxNode[], depth = 0) => {
return nodes.map((node) => {
@@ -2955,6 +2998,7 @@ export function EmailViewer({
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoveMenuOpen(false); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
@@ -2990,6 +3034,9 @@ export function EmailViewer({
onClick={() => { setTagMenuOpen(!tagMenuOpen); setMoreMenuOpen(false); setMoveMenuOpen(false); }}
className="h-8 rounded hover:bg-muted flex items-center gap-1.5 px-2"
title={t('set_tag')}
aria-label={t('set_tag')}
aria-haspopup="true"
aria-expanded={tagMenuOpen}
>
<Tag className="w-4 h-4" />
{showToolbarLabels && <span className="text-[10px] leading-tight sm:text-sm">{t('tag')}</span>}
@@ -3087,19 +3134,32 @@ export function EmailViewer({
{/* More menu - click-based */}
<div ref={moreMenuRef} className="relative">
<Button
ref={moreButtonRef}
variant="ghost"
size="sm"
className="flex-col items-center gap-0.5 h-auto py-1.5 px-2 sm:flex-row sm:h-8 sm:w-8 sm:gap-0 sm:py-0 sm:px-0"
title={t('more_actions')}
aria-label={t('more_actions')}
aria-haspopup="menu"
aria-expanded={moreMenuOpen}
aria-controls={moreMenuOpen ? moreMenuId : undefined}
onClick={() => { setMoreMenuOpen(!moreMenuOpen); setMoreMenuSub(null); setTagMenuOpen(false); setMoveMenuOpen(false); }}
>
<MoreVertical className="w-4 h-4 text-muted-foreground" />
<span className="text-[10px] leading-tight sm:hidden">{t('more_actions')}</span>
</Button>
{moreMenuOpen && !isMobile && (
<div className="absolute end-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10 py-1">
<div
ref={moreMenuListRef}
onKeyDown={onMoreMenuKeyDown}
id={moreMenuId}
role="menu"
aria-label={t('more_actions')}
className="absolute end-0 top-full mt-1 w-48 bg-background rounded-md shadow-lg border border-border z-10 py-1"
>
{/* Star toggle */}
<button
role="menuitem"
onClick={() => { onToggleStar?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
@@ -3108,6 +3168,7 @@ export function EmailViewer({
</button>
{/* Overflow: reply */}
<button
role="menuitem"
onClick={() => { onReply?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(1) ? "" : "sm:hidden")}
>
@@ -3116,6 +3177,7 @@ export function EmailViewer({
</button>
{/* Overflow: reply all */}
<button
role="menuitem"
onClick={() => { onReplyAll?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(2) ? "" : "sm:hidden")}
>
@@ -3124,6 +3186,7 @@ export function EmailViewer({
</button>
{/* Overflow: forward */}
<button
role="menuitem"
onClick={() => { onForward?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(3) ? "" : "sm:hidden")}
>
@@ -3132,6 +3195,7 @@ export function EmailViewer({
</button>
{/* Overflow: archive */}
<button
role="menuitem"
onClick={() => { onArchive?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(4) ? "" : "sm:hidden")}
>
@@ -3145,6 +3209,9 @@ export function EmailViewer({
onMouseLeave={() => setMoreMenuSub(null)}
>
<button
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'move'}
onClick={() => setMoreMenuSub(moreMenuSub === 'move' ? null : 'move')}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
@@ -3163,6 +3230,7 @@ export function EmailViewer({
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted flex items-center gap-2"
style={{ paddingLeft: `${0.75 + depth * 1}rem` }}
@@ -3197,6 +3265,9 @@ export function EmailViewer({
onMouseLeave={() => setMoreMenuSub(null)}
>
<button
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'tag'}
onClick={() => setMoreMenuSub(moreMenuSub === 'tag' ? null : 'tag')}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
@@ -3217,6 +3288,7 @@ export function EmailViewer({
{/* Overflow: spam */}
{spamApplicable && (onMarkAsSpam || onUndoSpam) && (
<button
role="menuitem"
onClick={() => { (isInJunkFolder ? onUndoSpam : onMarkAsSpam)?.(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(7) ? "" : "sm:hidden")}
>
@@ -3230,6 +3302,7 @@ export function EmailViewer({
)}
{/* Overflow: toggle read */}
<button
role="menuitem"
onClick={() => { onMarkAsRead?.(email.id, isUnread); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(8) ? "" : "sm:hidden")}
>
@@ -3238,6 +3311,7 @@ export function EmailViewer({
</button>
{/* Overflow: print */}
<button
role="menuitem"
onClick={() => { handlePrint(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(9) ? "" : "sm:hidden")}
>
@@ -3246,6 +3320,7 @@ export function EmailViewer({
</button>
{/* Overflow: view source */}
<button
role="menuitem"
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(10) ? "" : "sm:hidden")}
>
@@ -3255,6 +3330,7 @@ export function EmailViewer({
{/* Overflow: dark/light mode toggle */}
{effectiveEmailContent.isHtml && (
<button
role="menuitem"
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className={cn("w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2", hiddenPriorities.has(11) ? "" : "sm:hidden")}
>
@@ -3263,9 +3339,25 @@ export function EmailViewer({
</button>
)}
<div className="h-px bg-border my-1" />
{/* Permalink to this message (#733) */}
{email && (
<button
role="menuitem"
onClick={() => {
void copyLink(buildMailPath({ mailboxId: null, emailId: email.id, threadId: null }));
setMoreMenuOpen(false);
setMoreMenuSub(null);
}}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
<LinkIcon className="w-4 h-4" />
{tDeepLink('copy_message')}
</button>
)}
{/* Forward as attachment */}
{onForwardAsAttachment && email?.blobId && (
<button
role="menuitem"
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
@@ -3275,6 +3367,7 @@ export function EmailViewer({
)}
{/* Export email */}
<button
role="menuitem"
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
@@ -3283,6 +3376,7 @@ export function EmailViewer({
</button>
{/* Import email */}
<button
role="menuitem"
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
@@ -3291,6 +3385,7 @@ export function EmailViewer({
</button>
{onShowShortcuts && (
<button
role="menuitem"
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-3 py-1.5 text-sm text-start hover:bg-muted text-foreground flex items-center gap-2"
>
@@ -3319,7 +3414,17 @@ export function EmailViewer({
/>
)}
{!isScheduled && isMobile && (
<div className={cn(
<div
ref={mobileMoreRef}
onKeyDown={onMobileMoreKeyDown}
id={moreMenuId}
role="menu"
aria-label={t('more_actions')}
/* The panel is only slid off-screen, so without `inert` every action in
it stays permanently exposed to screen readers - and lands near the
top of the reading order, far from the toolbar it belongs to (#720). */
inert={!moreMenuOpen}
className={cn(
"fixed inset-y-0 right-0 w-72 bg-background border-s border-border z-[70] sm:hidden",
"transform transition-transform duration-300 ease-in-out",
"flex flex-col",
@@ -3328,6 +3433,7 @@ export function EmailViewer({
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
{moreMenuSub ? (
<button
role="menuitem"
onClick={() => setMoreMenuSub(null)}
className="flex items-center gap-1 -ms-2 px-2 py-1 rounded hover:bg-muted text-sm font-semibold text-foreground"
>
@@ -3337,7 +3443,14 @@ export function EmailViewer({
) : (
<span className="text-sm font-semibold text-foreground">{t('more_actions')}</span>
)}
<Button variant="ghost" size="icon" onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }} className="h-9 w-9">
<Button
variant="ghost"
size="icon"
role="menuitem"
aria-label={tCommon('close')}
onClick={() => { setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="h-9 w-9"
>
<X className="w-5 h-5" />
</Button>
</div>
@@ -3346,6 +3459,7 @@ export function EmailViewer({
<>
{/* Star toggle */}
<button
role="menuitem"
onClick={() => { onToggleStar?.(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3355,6 +3469,9 @@ export function EmailViewer({
{/* Tag (opens sub-view) */}
{(emailKeywords.length > 0 || currentTagIds.length > 0) && (
<button
role="menuitem"
aria-haspopup="menu"
aria-expanded={moreMenuSub === 'tag'}
onClick={() => setMoreMenuSub('tag')}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3374,6 +3491,7 @@ export function EmailViewer({
</button>
)}
<button
role="menuitem"
onClick={() => { handlePrint(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3381,6 +3499,7 @@ export function EmailViewer({
{t('print')}
</button>
<button
role="menuitem"
onClick={() => { setShowSourceModal(true); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3389,6 +3508,7 @@ export function EmailViewer({
</button>
{effectiveEmailContent.isHtml && (
<button
role="menuitem"
onClick={() => { setEmailViewDarkOverride(prev => prev === null ? !(resolvedTheme === 'dark') : !prev); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3399,6 +3519,7 @@ export function EmailViewer({
<div className="h-px bg-border my-1" />
{onForwardAsAttachment && email?.blobId && (
<button
role="menuitem"
onClick={() => { onForwardAsAttachment(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3407,6 +3528,7 @@ export function EmailViewer({
</button>
)}
<button
role="menuitem"
onClick={() => { handleExportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3414,6 +3536,7 @@ export function EmailViewer({
{t('export_email')}
</button>
<button
role="menuitem"
onClick={() => { handleImportEmail(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3422,6 +3545,7 @@ export function EmailViewer({
</button>
{onShowShortcuts && (
<button
role="menuitem"
onClick={() => { onShowShortcuts(); setMoreMenuOpen(false); }}
className="w-full px-4 py-3 min-h-[44px] text-sm text-start hover:bg-muted text-foreground flex items-center gap-3"
>
@@ -3440,6 +3564,7 @@ export function EmailViewer({
<div key={node.id}>
{isTarget ? (
<button
role="menuitem"
onClick={() => { onMoveToMailbox(node.id); setMoreMenuOpen(false); setMoreMenuSub(null); }}
className="w-full px-4 py-2.5 min-h-[44px] text-sm text-start hover:bg-muted flex items-center gap-3"
style={{ paddingLeft: `${1 + depth * 1}rem` }}
@@ -5065,7 +5190,7 @@ export function EmailViewer({
})()}
onClose={() => setContactSidebarEmail(null)}
onEditContact={sidebarContact ? () => {
router.push(`/contacts?contactId=${sidebarContact.id}&view=edit`);
router.push(buildContactsPath({ contactId: sidebarContact.id, editing: true }));
setContactSidebarEmail(null);
} : undefined}
onAddToContacts={(addr, name) => {
@@ -7,7 +7,7 @@ import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { useConfirmDialog } from "@/hooks/use-confirm-dialog";
import { useAuthStore, redirectToLogin } from "@/stores/auth-store";
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useAccountStore } from "@/stores/account-store";
import { useEmailStore } from "@/stores/email-store";
import { useFileStore } from "@/stores/file-store";
@@ -29,10 +29,21 @@ import { loadFilesSettings } from "@/components/files/files-settings-dialog";
import type { FolderLayout } from "@/components/files/files-settings-dialog";
import { AppTopBannerSlot } from "@/components/plugins/app-top-banner-slot";
import { AlertTriangle, Loader2 } from "lucide-react";
import { isFilePreviewable } from "@/lib/file-preview";
import { appPath, buildFilesPath, parseFilesPath, type FilesDeepLink } from "@/lib/deep-links";
import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
export default function FilesPage() {
export interface FilesAppProps {
/** Path segments after `/files` - the folder path, one segment per level. */
linkSegments?: string[];
}
export function FilesApp({ linkSegments }: FilesAppProps = {}) {
const router = useRouter();
const t = useTranslations("files");
const tDeepLink = useTranslations("deep_link");
const filesEnabled = usePolicyStore((s) => s.isFeatureEnabled('filesEnabled'));
const { isAuthenticated, logout, checkAuth, isLoading: authLoading, client } = useAuthStore();
const activeAccountId = useAuthStore((s) => s.activeAccountId);
@@ -133,7 +144,7 @@ export default function FilesPage() {
// Redirect if not authenticated
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
saveRedirectAfterLogin();
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
@@ -160,20 +171,78 @@ export default function FilesPage() {
},
});
// Check support and load root after client is initialized
const storeClient = useFileStore(s => s.client);
// ---- Deep links (#733) ---------------------------------------------------
// `/files/<folder>/<sub>` walks the drive to that folder; `?preview=<name>`
// opens a file there once the listing arrives. The link is parked on mount
// and claimed by whichever of the two paths below gets there first: the
// bootstrap that runs after a capability check on a cold load, or the effect
// for when the store already knows files are supported.
const filesLinkRef = useRef<FilesDeepLink | null>(null);
const pendingPreviewRef = useRef<string | null>(null);
useEffect(() => {
if (storeClient && supportsFiles === null) {
checkSupport().then(async (supported) => {
if (supported) {
// Upgrade any files created by older builds (flat path-encoded names)
// into the real FileNode hierarchy before the first listing.
await migrateLegacyFlatNodes();
navigate(null);
}
});
// Inside the Pro shell the route is /pro, so the segments arrive through
// the handoff the redirect parked rather than as route params.
const segments = linkSegments ?? consumePendingDeepLink('files');
if (!segments) return;
const link = parseFilesPath(segments, new URLSearchParams(window.location.search));
// Never overwrite with null: this effect re-runs (twice on mount under
// StrictMode) and the handoff only yields its segments once.
if (link) filesLinkRef.current = link;
}, [linkSegments]);
const takeFilesLink = useCallback(() => {
const link = filesLinkRef.current;
filesLinkRef.current = null;
if (link?.preview) pendingPreviewRef.current = link.preview;
return link;
}, []);
// Check support and load the first listing after the client is initialized.
// One effect, run once: `checkSupport` publishes `supportsFiles` before it
// resolves, so splitting the cold path from the warm one would have them
// race - the warm one following the deep link while the cold one, a tick
// later, listed the root on top of it.
const storeClient = useFileStore(s => s.client);
const filesBootstrapRef = useRef(false);
useEffect(() => {
if (filesBootstrapRef.current) return;
if (!storeClient) return;
filesBootstrapRef.current = true;
void (async () => {
const firstCheck = supportsFiles === null;
if (firstCheck) {
if (!await checkSupport()) return;
// Upgrade any files created by older builds (flat path-encoded names)
// into the real FileNode hierarchy before the first listing.
await migrateLegacyFlatNodes();
} else if (supportsFiles !== true) {
return;
}
// A deep link replaces the root listing rather than following it -
// listing the root first would strand the user there and rewrite the
// URL they arrived on.
const link = takeFilesLink();
if (link && link.path !== '/') await navigateByPath(link.path);
else if (firstCheck) await navigate(null);
})();
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate, navigateByPath, takeFilesLink]);
// The preview waits for the folder listing: the modal is keyed by file name
// and can only render once that name is in `resources`.
useEffect(() => {
const pending = pendingPreviewRef.current;
if (!pending) return;
const resource = resources.find((r) => r.name === pending);
if (!resource) return;
pendingPreviewRef.current = null;
if (isFilePreviewable(pending)) {
setPreviewFile(pending);
} else {
toast.error(tDeepLink('file_not_found'));
}
}, [storeClient, supportsFiles, checkSupport, migrateLegacyFlatNodes, navigate]);
}, [resources, tDeepLink]);
const handleNavigate = useCallback((path: string, resourceId?: string | null) => {
// Pro shell only: the Account breadcrumb segment signals "go to this
@@ -397,6 +466,15 @@ export default function FilesPage() {
setShowDetails(true);
}, []);
// The permalink for the folder on screen. Suppressed inside the Pro shell,
// where /pro owns the address bar, and at the account picker (no path yet).
const proInterfaceActive = useProInterfaceActive();
useDeepLinkUrl(
isEmbedded || proInterfaceActive
? null
: appPath(buildFilesPath(currentPath, previewFile)),
);
const handleToggleDetails = useCallback(() => {
setShowDetails(v => !v);
}, []);
+20 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { useState, useRef, useEffect, useCallback, useMemo, useId } from "react";
import { createPortal } from "react-dom";
import { Check, Plus, LogOut, Star, ChevronDown, AlertCircle, GripVertical, X } from "lucide-react";
import { useTranslations } from "next-intl";
@@ -11,6 +11,7 @@ import { isDocumentRTL } from "@/i18n/direction";
import { cn } from "@/lib/utils";
import { useRouter } from "@/i18n/navigation";
import { Avatar } from "@/components/ui/avatar";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
interface AccountSwitcherProps {
/** "rail" = small avatar only (NavigationRail), "expanded" = avatar + name + email (Sidebar) */
@@ -36,8 +37,17 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
const router = useRouter();
const [open, setOpen] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
const popoverRef = useRef<HTMLDivElement>(null);
const menuId = useId();
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
// The popover is portalled to <body>, so it lands at the very end of the
// reading order. Without this the menu is unreachable for screen reader and
// keyboard users even though it is on screen.
const closeMenu = useCallback(() => setOpen(false), []);
const { menuRef: popoverRef, onKeyDown: handleMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open,
onClose: closeMenu,
triggerRef: buttonRef,
});
const accounts = useAccountStore((s) => s.accounts);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
@@ -106,7 +116,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [open, updatePosition]);
}, [open, updatePosition, popoverRef]);
const handleSwitch = async (accountId: string) => {
if (accountId === activeAccountId) return;
@@ -187,8 +197,10 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
className
)}
title={variant === "rail" ? (displayName || displayEmail) : undefined}
aria-label={t("switch_account")}
aria-expanded={open}
aria-haspopup="true"
aria-haspopup="menu"
aria-controls={open ? menuId : undefined}
>
{activeAccount ? (
<>
@@ -219,6 +231,9 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
style={popoverStyle}
className="w-72 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
role="menu"
id={menuId}
aria-label={t("switch_account")}
onKeyDown={handleMenuKeyDown}
>
{/* Account List */}
<div className="py-1 max-h-64 overflow-y-auto">
@@ -299,6 +314,7 @@ export function AccountSwitcher({ variant = "rail", className }: AccountSwitcher
<button
type="button"
onClick={(e) => handleRemove(e, account)}
role="menuitem"
aria-label={t("remove_account")}
title={t("remove_account")}
className="absolute end-1.5 top-1/2 -translate-y-1/2 p-1 rounded-md text-muted-foreground/60 opacity-0 transition-opacity group-hover/acct:opacity-100 hover:bg-destructive/10 hover:text-destructive focus:opacity-100 focus:outline-none focus:ring-1 focus:ring-destructive"
+6 -1
View File
@@ -14,6 +14,8 @@ interface MobileHeaderProps {
onCompose?: () => void;
onSearch?: () => void;
className?: string;
/** Id of the sidebar this header's menu button toggles. */
sidebarId?: string;
}
export function MobileHeader({
@@ -23,6 +25,7 @@ export function MobileHeader({
onCompose,
onSearch,
className,
sidebarId,
}: MobileHeaderProps) {
const t = useTranslations('sidebar');
const { toggleSidebar, goBack, sidebarOpen } = useUIStore();
@@ -60,8 +63,10 @@ export function MobileHeader({
"h-11 w-11",
!showBack && sidebarOpen && "bg-accent"
)}
aria-label={showBack ? "Go back" : "Toggle menu"}
data-sidebar-toggle={!showBack ? "" : undefined}
aria-label={showBack ? t('mobile.go_back') : t('mobile.toggle_menu')}
aria-expanded={!showBack ? sidebarOpen : undefined}
aria-controls={!showBack ? sidebarId : undefined}
>
{showBack ? (
<ArrowLeft className="h-5 w-5" />
+14 -3
View File
@@ -20,6 +20,7 @@ import { getActiveAccountSlotHeaders } from "@/lib/auth/active-account-slot";
import { getMaxAccounts } from "@/lib/account-utils";
import { isDocumentRTL } from "@/i18n/direction";
import { cn, formatFileSize } from "@/lib/utils";
import { useMenuNavigation } from "@/hooks/use-menu-navigation";
import { PluginSlot } from "@/components/plugins/plugin-slot";
import { KeyboardShortcutsModal } from "@/components/keyboard-shortcuts-modal";
import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation";
@@ -220,7 +221,14 @@ export function NavigationRail({
const logoutAll = useAuthStore((s) => s.logoutAll);
const [logoutMenuOpen, setLogoutMenuOpen] = useState(false);
const logoutBtnRef = useRef<HTMLButtonElement>(null);
const logoutPopoverRef = useRef<HTMLDivElement>(null);
// Portalled to <body>, so without explicit focus handling the menu is
// unreachable for keyboard and screen reader users (#719).
const closeLogoutMenu = useCallback(() => setLogoutMenuOpen(false), []);
const { menuRef: logoutPopoverRef, onKeyDown: onLogoutMenuKeyDown } = useMenuNavigation<HTMLDivElement>({
open: logoutMenuOpen,
onClose: closeLogoutMenu,
triggerRef: logoutBtnRef,
});
const [logoutPopoverStyle, setLogoutPopoverStyle] = useState<React.CSSProperties>({});
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
@@ -261,7 +269,7 @@ export function NavigationRail({
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [logoutMenuOpen, updateLogoutPosition]);
}, [logoutMenuOpen, updateLogoutPosition, logoutPopoverRef]);
useEffect(() => {
let cancelled = false;
@@ -713,8 +721,9 @@ export function NavigationRail({
onClick={() => setLogoutMenuOpen(!logoutMenuOpen)}
className="flex items-center justify-center w-9 h-9 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
title={t("sign_out")}
aria-label={t("sign_out")}
aria-expanded={logoutMenuOpen}
aria-haspopup="true"
aria-haspopup="menu"
>
<LogOut className="w-4 h-4" />
</button>
@@ -722,9 +731,11 @@ export function NavigationRail({
{logoutMenuOpen && createPortal(
<div
ref={logoutPopoverRef}
onKeyDown={onLogoutMenuKeyDown}
style={logoutPopoverStyle}
className="w-56 rounded-lg border border-border bg-background text-foreground shadow-lg z-50 overflow-hidden"
role="menu"
aria-label={t("sign_out")}
>
<button
onClick={() => { setLogoutMenuOpen(false); logout(); }}
+6 -8
View File
@@ -67,6 +67,7 @@ import { toast } from "@/stores/toast-store";
import { debug } from "@/lib/debug";
import { AccountSwitcher } from "./account-switcher";
import { useIsEmbedded } from "@/hooks/use-is-embedded";
import { buildSettingsPath } from "@/lib/deep-links";
import { useTour } from "@/components/tour/tour-provider";
interface SidebarProps {
@@ -1078,14 +1079,11 @@ export function Sidebar({
});
};
const openFolderSettings = () => {
try { sessionStorage.setItem('settings-deep-link-tab', 'folders'); } catch { /* */ }
router.push('/settings');
};
const openKeywordSettings = () => {
try { sessionStorage.setItem('settings-deep-link-tab', 'keywords'); } catch { /* */ }
router.push('/settings');
};
// The tab now travels in the URL (#733) rather than through sessionStorage,
// so a gear click is a link the user can also bookmark or share. It still
// steers only this one open - the persisted default is written on tab click.
const openFolderSettings = () => { router.push(buildSettingsPath('folders')); };
const openKeywordSettings = () => { router.push(buildSettingsPath('keywords')); };
const {
contextMenu: mailboxContextMenu,
File diff suppressed because it is too large Load Diff
+30 -9
View File
@@ -5,20 +5,38 @@ import { usePathname, useRouter } from "@/i18n/navigation";
import { useSettingsStore } from "@/stores/settings-store";
import { useIsDesktop } from "@/hooks/use-media-query";
import { useProTabStore, type ProTabKind } from "@/stores/pro-tab-store";
import { matchSurface, type AppSurface } from "@/lib/deep-links";
import { setPendingDeepLink } from "@/lib/deep-link-handoff";
const STANDARD_PATH_TO_TAB: Record<string, Exclude<ProTabKind, 'compose' | 'email'>> = {
'/': 'mail',
'/calendar': 'calendar',
'/contacts': 'contacts',
'/files': 'files',
'/settings': 'settings',
const SURFACE_TO_TAB: Record<AppSurface, Exclude<ProTabKind, 'compose' | 'email'>> = {
mail: 'mail',
calendar: 'calendar',
contacts: 'contacts',
files: 'files',
settings: 'settings',
};
/**
* Whether the Pro shell is currently taking over the standard routes. Surfaces
* use this to keep off the address bar: under Pro the only route is /pro, so a
* surface rewriting the URL to its own permalink would fight the redirect.
*/
export function useProInterfaceActive(): boolean {
const proInterface = useSettingsStore((s) => s.proInterface);
const isDesktop = useIsDesktop();
return proInterface && isDesktop;
}
/**
* When the Pro interface is enabled, the standard mail/calendar/contacts/
* files/settings routes are taken over by the Pro shell - the user shouldn't
* have to click "Open" in settings to land there. Mobile/tablet keeps the
* standard layout because Pro is desktop-only (see pro/page.tsx).
*
* Surfaces are matched by prefix rather than by exact path, and any deep-link
* segments are handed to the tab that opens (#733), so `/mail/message/<id>`
* opens that message in a Pro mail tab instead of silently landing on the
* inbox.
*/
export function ProInterfaceRedirect() {
const router = useRouter();
@@ -28,9 +46,12 @@ export function ProInterfaceRedirect() {
useEffect(() => {
if (!proInterface || !isDesktop) return;
const tabKind = STANDARD_PATH_TO_TAB[pathname];
if (!tabKind) return;
useProTabStore.getState().openTab(tabKind);
const match = matchSurface(pathname);
if (!match) return;
if (match.segments.length > 0) {
setPendingDeepLink(match.surface, match.segments);
}
useProTabStore.getState().openTab(SURFACE_TO_TAB[match.surface]);
router.replace('/pro');
}, [proInterface, isDesktop, pathname, router]);
@@ -0,0 +1,61 @@
import { render, screen } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { SettingItem, ToggleSwitch, RadioGroup, Select } from '../settings-section';
describe('SettingItem controls', () => {
it('names a toggle after the row label and reports its state', () => {
render(
<SettingItem label="Conversation view">
<ToggleSwitch checked onChange={() => {}} />
</SettingItem>
);
const toggle = screen.getByRole('switch', { name: 'Conversation view' });
expect(toggle).toHaveAttribute('aria-checked', 'true');
});
it('lets an explicit ariaLabel win over the row label', () => {
render(
<SettingItem label="Conversation view">
<ToggleSwitch checked={false} onChange={() => {}} ariaLabel="Group by thread" />
</SettingItem>
);
expect(screen.getByRole('switch', { name: 'Group by thread' })).toHaveAttribute('aria-checked', 'false');
});
it('reports which option of a segmented control is active', () => {
render(
<SettingItem label="Density">
<RadioGroup
value="cosy"
onChange={() => {}}
options={[
{ value: 'compact', label: 'Compact' },
{ value: 'cosy', label: 'Cosy' },
]}
/>
</SettingItem>
);
expect(screen.getByRole('button', { name: 'Cosy' })).toHaveAttribute('aria-pressed', 'true');
expect(screen.getByRole('button', { name: 'Compact' })).toHaveAttribute('aria-pressed', 'false');
expect(screen.getByRole('group', { name: 'Density' })).toBeTruthy();
});
it('names a select after the row label', () => {
render(
<SettingItem label="Time format">
<Select value="24h" onChange={() => {}} options={[{ value: '24h', label: '24 hour' }]} />
</SettingItem>
);
expect(screen.getByRole('combobox', { name: 'Time format' })).toBeTruthy();
});
it('works standalone, outside a SettingItem', () => {
render(<ToggleSwitch checked onChange={() => {}} ariaLabel="Standalone" />);
expect(screen.getByRole('switch', { name: 'Standalone' })).toBeTruthy();
});
});
@@ -69,7 +69,11 @@ import { PluginsSettings } from '@/components/settings/plugins-settings';
import { PluginIframeSlot } from '@/components/plugins/plugin-iframe-slot';
import { offersForSlot as pluginOffersForSlot, subscribe as pluginRegistrySubscribe, get as getActivePlugin } from '@/lib/plugin-sandbox/registry';
import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings';
import { useAuthStore, redirectToLogin } from '@/stores/auth-store';
import { appPath, buildSettingsPath, parseSettingsPath } from '@/lib/deep-links';
import { consumePendingDeepLink } from '@/lib/deep-link-handoff';
import { useDeepLinkUrl } from '@/hooks/use-deep-link-url';
import { useProInterfaceActive } from '@/components/pro/pro-interface-redirect';
import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePluginStore } from '@/stores/plugin-store';
import { useThemeStore } from '@/stores/theme-store';
@@ -361,7 +365,27 @@ function readPersistedTab(): SettingsTabId {
}
}
export default function SettingsPage() {
/**
* The tab a `/settings/<tab>` deep link names (#733), or null when the URL
* names nothing we recognise - an old link to a tab that has since been
* renamed falls back to the persisted tab rather than rendering an empty pane.
* Legacy ids are migrated the same way the stored value is.
*/
function tabFromDeepLink(segments: string[] | null | undefined): SettingsTabId | null {
const raw = parseSettingsPath(segments ?? []);
if (!raw) return null;
const migrated = raw in LEGACY_TAB_MAP ? LEGACY_TAB_MAP[raw] : raw;
if (migrated in tabIcons) return migrated as SettingsTabId;
if (migrated.startsWith('plugin:')) return migrated as SettingsTabId;
return null;
}
export interface SettingsAppProps {
/** Path segments after `/settings` - `['<tabId>']`. */
linkSegments?: string[];
}
export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
const router = useRouter();
const t = useTranslations('settings');
const tSidebar = useTranslations('sidebar');
@@ -372,7 +396,10 @@ export default function SettingsPage() {
const { quota, isPushConnected } = useEmailStore();
const { stalwartFeaturesEnabled } = useConfig();
const { isFeatureEnabled } = usePolicyStore();
const [activeTab, setActiveTab] = useState<SettingsTabId>(readPersistedTab);
// A tab in the URL wins over the persisted one; without it, nothing changes.
const [activeTab, setActiveTab] = useState<SettingsTabId>(
() => tabFromDeepLink(linkSegments) ?? readPersistedTab(),
);
// Active plugins that expose a `settings-section` slot — each becomes its own
// Settings menu entry. Referentially stable per registry mutation, so it is
// safe to feed useSyncExternalStore directly.
@@ -386,6 +413,13 @@ export default function SettingsPage() {
useEffect(() => {
try { sessionStorage.removeItem('settings-deep-link-tab'); } catch { /* ignore */ }
}, []);
// Inside the Pro shell there is no route to read the tab from, so it arrives
// through the handoff the redirect parked (#733).
useEffect(() => {
if (linkSegments) return;
const tab = tabFromDeepLink(consumePendingDeepLink('settings'));
if (tab) setActiveTab(tab);
}, [linkSegments]);
const [mobileShowContent, setMobileShowContent] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
@@ -512,7 +546,7 @@ export default function SettingsPage() {
useEffect(() => {
if (initialCheckDone && !isAuthenticated && !authLoading) {
try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ }
saveRedirectAfterLogin();
redirectToLogin();
}
}, [initialCheckDone, isAuthenticated, authLoading]);
@@ -599,6 +633,17 @@ export default function SettingsPage() {
};
}, [pendingHighlight, activeTab]);
// The permalink for the open tab (#733). Suppressed inside the Pro shell,
// where /pro owns the address bar. On mobile the tab list is its own screen,
// so the tab is only in the URL once the user is looking at its content.
// Must sit above the early return below - it is a hook.
const proInterfaceActive = useProInterfaceActive();
useDeepLinkUrl(
!isAuthenticated || isEmbedded || proInterfaceActive
? null
: appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab)),
);
if (!isAuthenticated) {
return null;
}
+26 -5
View File
@@ -1,7 +1,16 @@
import { ReactNode } from 'react';
'use client';
import { ReactNode, createContext, useContext, useId } from 'react';
import { Lock } from 'lucide-react';
import { cn } from '@/lib/utils';
/**
* Lets the controls inside a SettingItem borrow the row's visible label as
* their accessible name. Without it a bare toggle announces as an unnamed
* switch, since the row label is a <label> with nothing to point at (#722).
*/
const SettingLabelContext = createContext<string | undefined>(undefined);
interface SettingsSectionProps {
title: string;
description?: string;
@@ -30,6 +39,7 @@ interface SettingItemProps {
}
export function SettingItem({ label, description, children, locked }: SettingItemProps) {
const labelId = useId();
return (
<div
data-search-label={label}
@@ -37,14 +47,16 @@ export function SettingItem({ label, description, children, locked }: SettingIte
>
<div className="flex-1 min-w-0 sm:pe-4">
<div className="flex items-center gap-1.5">
<label className="text-sm font-medium text-foreground">{label}</label>
<label id={labelId} className="text-sm font-medium text-foreground">{label}</label>
{locked && <Lock className="w-3 h-3 text-muted-foreground" aria-label="Managed by administrator" />}
</div>
{description && (
<p className="text-xs text-muted-foreground mt-1">{description}</p>
)}
</div>
<div className={cn("flex-shrink-0", locked && "pointer-events-none")}>{children}</div>
<SettingLabelContext.Provider value={labelId}>
<div className={cn("flex-shrink-0", locked && "pointer-events-none")}>{children}</div>
</SettingLabelContext.Provider>
</div>
);
}
@@ -53,14 +65,19 @@ interface ToggleSwitchProps {
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
/** Overrides the enclosing SettingItem label as the accessible name. */
ariaLabel?: string;
}
export function ToggleSwitch({ checked, onChange, disabled }: ToggleSwitchProps) {
export function ToggleSwitch({ checked, onChange, disabled, ariaLabel }: ToggleSwitchProps) {
const labelledBy = useContext(SettingLabelContext);
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : labelledBy}
disabled={disabled}
onClick={() => onChange(!checked)}
className={cn(
@@ -86,12 +103,14 @@ interface RadioGroupProps {
}
export function RadioGroup({ value, onChange, options }: RadioGroupProps) {
const labelledBy = useContext(SettingLabelContext);
return (
<div className="flex gap-1.5">
<div className="flex gap-1.5" role="group" aria-labelledby={labelledBy}>
{options.map((option) => (
<button
key={option.value}
type="button"
aria-pressed={value === option.value}
onClick={() => onChange(option.value)}
className={cn(
'px-3 py-1.5 text-xs rounded-md transition-colors duration-150',
@@ -117,12 +136,14 @@ interface SelectProps {
}
export function Select({ value, onChange, options, disabled, className, ariaLabel }: SelectProps) {
const labelledBy = useContext(SettingLabelContext);
return (
<select
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : labelledBy}
dir="auto"
className={cn(
"px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150",
@@ -150,6 +150,9 @@ function AppForm({
<label className="text-sm font-medium">{t("show_on_mobile")}</label>
<button
type="button"
role="switch"
aria-checked={formData.showOnMobile}
aria-label={t("show_on_mobile")}
onClick={() => setFormData({ ...formData, showOnMobile: !formData.showOnMobile })}
className={cn(
"relative inline-flex h-5 w-9 items-center rounded-full transition-colors",
+33 -23
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useRef, useEffect } from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { useLocaleStore } from '@/stores/locale-store';
import { ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useMenuNavigation } from '@/hooks/use-menu-navigation';
import { flagComponents } from './flag-icons';
const languages = [
@@ -45,7 +46,13 @@ export function LanguageSwitcher({ className }: { className?: string }) {
const choice = useLocaleStore((state) => state.locale) || 'auto';
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLUListElement>(null);
const buttonRef = useRef<HTMLButtonElement>(null);
const close = useCallback(() => setOpen(false), []);
const { menuRef: listRef, onKeyDown: onListKeyDown } = useMenuNavigation<HTMLUListElement>({
open,
onClose: close,
triggerRef: buttonRef,
});
const current = languages.find((l) => l.value === choice) ?? languages[0];
@@ -75,9 +82,10 @@ export function LanguageSwitcher({ className }: { className?: string }) {
<div ref={containerRef} className={cn("relative", className)}>
<button
type="button"
ref={buttonRef}
onClick={() => setOpen((v) => !v)}
className="flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-muted border border-border text-foreground hover:border-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring transition-colors duration-150 cursor-pointer w-full"
aria-haspopup="listbox"
aria-haspopup="menu"
aria-expanded={open}
>
<FlagIcon locale={current.value} />
@@ -88,29 +96,31 @@ export function LanguageSwitcher({ className }: { className?: string }) {
{open && (
<ul
ref={listRef}
role="listbox"
aria-activedescendant={`lang-${choice}`}
role="menu"
onKeyDown={onListKeyDown}
className="absolute start-0 z-50 mt-1 min-w-full w-max max-w-[min(16rem,80vw)] max-h-60 overflow-auto rounded-md border border-border bg-background shadow-lg py-1"
>
{languages.map((lang) => (
<li
key={lang.value}
id={`lang-${lang.value}`}
role="option"
aria-selected={lang.value === choice}
onClick={() => {
setLocale(lang.value);
setOpen(false);
}}
className={cn(
"flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer whitespace-nowrap transition-colors duration-100",
lang.value === choice
? "bg-accent text-accent-foreground font-medium"
: "text-foreground hover:bg-accent/50"
)}
>
<FlagIcon locale={lang.value} />
<span>{lang.label}</span>
<li key={lang.value} role="none">
<button
type="button"
id={`lang-${lang.value}`}
role="menuitemradio"
aria-checked={lang.value === choice}
onClick={() => {
setLocale(lang.value);
setOpen(false);
}}
className={cn(
"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-start cursor-pointer whitespace-nowrap transition-colors duration-100",
lang.value === choice
? "bg-accent text-accent-foreground font-medium"
: "text-foreground hover:bg-accent/50"
)}
>
<FlagIcon locale={lang.value} />
<span>{lang.label}</span>
</button>
</li>
))}
</ul>
@@ -0,0 +1,100 @@
import { render, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useBrowserNavigation, type NavSnapshot } from '../use-browser-navigation';
/**
* The hook is the mail client's history layer: it records every mailbox /
* message / thread the user opens so back and forward walk the mail UI, and -
* since #733 - writes a permalink into the address bar alongside each entry.
* These tests pin the URL half: it must be opt-in (state-only without a
* builder), must survive the synthetic "list" entry seeded for links opened
* cold, and must never fight a back/forward restore.
*/
interface HarnessProps extends NavSnapshot {
withUrl?: boolean;
onRestore?: (state: NavSnapshot) => void;
}
function Harness({ withUrl = true, onRestore = () => {}, ...snapshot }: HarnessProps) {
useBrowserNavigation({
...snapshot,
onRestore,
buildUrl: withUrl
? (s) => (s.emailId ? `/mail/message/${s.emailId}` : '/mail')
: undefined,
});
return null;
}
const EMPTY: NavSnapshot = {
mailboxId: null,
emailId: null,
threadId: null,
composerOpen: false,
sidebarOpen: false,
};
let pushSpy: ReturnType<typeof vi.spyOn>;
let replaceSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
window.history.replaceState(null, '', '/');
pushSpy = vi.spyOn(window.history, 'pushState');
replaceSpy = vi.spyOn(window.history, 'replaceState');
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('useBrowserNavigation - permalinks', () => {
it('leaves the URL alone when no builder is supplied', () => {
const { rerender } = render(<Harness {...EMPTY} withUrl={false} mailboxId="inbox" />);
rerender(<Harness {...EMPTY} withUrl={false} mailboxId="inbox" emailId="m1" />);
for (const call of [...pushSpy.mock.calls, ...replaceSpy.mock.calls]) {
expect(call[2]).toBeUndefined();
}
});
it('writes the permalink for each new history entry', () => {
const { rerender } = render(<Harness {...EMPTY} mailboxId="inbox" />);
// First run replaces rather than pushes, so opening the app doesn't leave
// an extra entry the user has to back through to leave.
expect(replaceSpy).toHaveBeenLastCalledWith(expect.anything(), '', '/mail');
rerender(<Harness {...EMPTY} mailboxId="inbox" emailId="m1" />);
expect(pushSpy).toHaveBeenLastCalledWith(expect.anything(), '', '/mail/message/m1');
});
it('seeds a list entry behind a message opened cold, with its own URL', () => {
// Mounting straight onto a message is what a deep link does: back has to
// land on the list, not leave the app.
render(<Harness {...EMPTY} mailboxId="inbox" emailId="m1" />);
expect(replaceSpy).toHaveBeenLastCalledWith(expect.anything(), '', '/mail');
expect(pushSpy).toHaveBeenLastCalledWith(expect.anything(), '', '/mail/message/m1');
});
it('does not push a new entry while restoring a back/forward navigation', async () => {
const restored: NavSnapshot[] = [];
const { rerender } = render(
<Harness {...EMPTY} mailboxId="inbox" onRestore={(s) => { restored.push(s); }} />,
);
rerender(<Harness {...EMPTY} mailboxId="inbox" emailId="m1" onRestore={(s) => { restored.push(s); }} />);
pushSpy.mockClear();
await act(async () => {
window.dispatchEvent(new PopStateEvent('popstate', {
state: { __mailNav: { ...EMPTY, mailboxId: 'inbox', navId: 1 } },
}));
});
// The page applies the restored snapshot; the hook must treat the
// resulting render as a replay, not as a fresh user navigation.
rerender(<Harness {...EMPTY} mailboxId="inbox" onRestore={(s) => { restored.push(s); }} />);
expect(restored).toHaveLength(1);
expect(pushSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,109 @@
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { useRef, useState } from 'react';
import { useMenuNavigation } from '../use-menu-navigation';
/**
* The hook defers its first focus by a frame so portals can mount; drive rAF
* synchronously so tests don't have to wait on the real scheduler.
*/
function flushFrames() {
act(() => {
vi.advanceTimersByTime(32);
});
}
function Menu({ initialOpen = false }: { initialOpen?: boolean }) {
const [open, setOpen] = useState(initialOpen);
const triggerRef = useRef<HTMLButtonElement>(null);
const { menuRef, onKeyDown } = useMenuNavigation<HTMLDivElement>({
open,
onClose: () => setOpen(false),
triggerRef,
});
return (
<>
<button ref={triggerRef} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)}>
Open
</button>
{open && (
<div ref={menuRef} role="menu" onKeyDown={onKeyDown}>
<button role="menuitem">First</button>
<button role="menuitem">Second</button>
<button role="menuitem" disabled>Skipped</button>
<button role="menuitem">Third</button>
</div>
)}
</>
);
}
describe('useMenuNavigation', () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ['requestAnimationFrame', 'cancelAnimationFrame', 'setTimeout'] });
});
afterEach(() => {
vi.useRealTimers();
});
it('moves focus to the first item when the menu opens', () => {
render(<Menu />);
fireEvent.click(screen.getByText('Open'));
flushFrames();
expect(document.activeElement).toBe(screen.getByText('First'));
});
it('cycles through items with the arrow keys, skipping disabled ones', () => {
render(<Menu initialOpen />);
flushFrames();
const menu = screen.getByRole('menu');
fireEvent.keyDown(menu, { key: 'ArrowDown' });
expect(document.activeElement).toBe(screen.getByText('Second'));
fireEvent.keyDown(menu, { key: 'ArrowDown' });
expect(document.activeElement).toBe(screen.getByText('Third'));
// Wraps back round to the top rather than dead-ending.
fireEvent.keyDown(menu, { key: 'ArrowDown' });
expect(document.activeElement).toBe(screen.getByText('First'));
fireEvent.keyDown(menu, { key: 'ArrowUp' });
expect(document.activeElement).toBe(screen.getByText('Third'));
});
it('jumps to the ends with Home and End', () => {
render(<Menu initialOpen />);
flushFrames();
const menu = screen.getByRole('menu');
fireEvent.keyDown(menu, { key: 'End' });
expect(document.activeElement).toBe(screen.getByText('Third'));
fireEvent.keyDown(menu, { key: 'Home' });
expect(document.activeElement).toBe(screen.getByText('First'));
});
it('closes on Escape and returns focus to the trigger', () => {
render(<Menu initialOpen />);
flushFrames();
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
flushFrames();
expect(screen.queryByRole('menu')).toBeNull();
expect(document.activeElement).toBe(screen.getByText('Open'));
});
it('closes when focus tabs out of the menu', () => {
render(<Menu initialOpen />);
flushFrames();
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Tab' });
expect(screen.queryByRole('menu')).toBeNull();
});
});
+23 -5
View File
@@ -17,6 +17,13 @@ interface StoredNavState extends NavSnapshot {
interface UseBrowserNavigationOptions extends NavSnapshot {
onRestore: (state: NavSnapshot) => void | Promise<void>;
enabled?: boolean;
/**
* Permalink for a snapshot (#733). When supplied, the URL is rewritten
* along with the history entry so the address bar is always a shareable
* link to what the user is looking at. Return null to leave the URL alone
* for that snapshot. Omit the option entirely for state-only history.
*/
buildUrl?: (state: NavSnapshot) => string | null | undefined;
}
const STATE_KEY = "__mailNav";
@@ -53,8 +60,10 @@ function readStoredState(): StoredNavState | undefined {
* - Listens for popstate and calls onRestore so the page can apply the
* previous snapshot (mailbox, email, view, sidebar, conversation thread).
*
* The URL is left untouched so Next.js routes (e.g. /calendar, /settings)
* continue to behave normally.
* Without `buildUrl` the URL is left untouched and only history state moves.
* With it, each entry also carries a permalink, written through
* history.push/replaceState - shallow routing that Next.js supports natively,
* so the route tree is not re-rendered on every message the user opens.
*/
export function useBrowserNavigation({
mailboxId,
@@ -64,17 +73,22 @@ export function useBrowserNavigation({
sidebarOpen,
onRestore,
enabled = true,
buildUrl,
}: UseBrowserNavigationOptions) {
// Counter so overlapping restores don't accidentally clear the flag
// belonging to a later restore.
const popDepthRef = useRef(0);
const isApplyingPopRef = useRef(false);
const restoreRef = useRef(onRestore);
const buildUrlRef = useRef(buildUrl);
const initializedRef = useRef(false);
// Always keep the latest restore callback in a ref so the popstate
// listener never sees a stale closure.
restoreRef.current = onRestore;
// Same for the URL builder: it closes over the mailbox list, which arrives
// after the first render, and the effect below must not re-run for it.
buildUrlRef.current = buildUrl;
// Install the popstate listener once.
useEffect(() => {
@@ -144,6 +158,9 @@ export function useBrowserNavigation({
const stored: StoredNavState = { ...snapshot, navId: ++navIdCounter };
const baseState = (window.history.state ?? {}) as Record<string, unknown>;
const newState = { ...baseState, [STATE_KEY]: stored };
// `history.pushState(state, "", undefined)` keeps the current URL, which is
// exactly the old state-only behaviour when no builder is supplied.
const newUrl = buildUrlRef.current?.(snapshot) ?? undefined;
if (!initializedRef.current) {
initializedRef.current = true;
@@ -171,16 +188,17 @@ export function useBrowserNavigation({
window.history.replaceState(
{ ...baseState, [STATE_KEY]: listStored },
"",
buildUrlRef.current?.(listSnapshot) ?? undefined,
);
// Now push the actual email state on top of the synthetic list entry.
window.history.pushState(newState, "");
window.history.pushState(newState, "", newUrl);
} else {
// Replace the current entry on the very first run so we don't
// create an extra step the user has to back through to leave the app.
window.history.replaceState(newState, "");
window.history.replaceState(newState, "", newUrl);
}
} else {
window.history.pushState(newState, "");
window.history.pushState(newState, "", newUrl);
}
}, [enabled, mailboxId, emailId, threadId, composerOpen, sidebarOpen]);
}
+27
View File
@@ -0,0 +1,27 @@
"use client";
import { useCallback } from "react";
import { useTranslations } from "next-intl";
import { appUrl } from "@/lib/deep-links";
import { toast } from "@/stores/toast-store";
/**
* Puts a permalink to an app-relative path on the clipboard (#733), as the
* absolute URL a colleague or a dashboard can actually open.
*
* The Clipboard API is unavailable on insecure origins and can be refused by
* permission policy, so failures are reported rather than swallowed - a button
* that silently does nothing reads as a broken link.
*/
export function useCopyLink(): (path: string) => Promise<void> {
const t = useTranslations("deep_link");
return useCallback(async (path: string) => {
try {
await navigator.clipboard.writeText(appUrl(path));
toast.success(t("copied"));
} catch {
toast.error(t("copy_failed"));
}
}, [t]);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { useEffect, useRef } from "react";
/**
* Keeps the address bar in step with what a surface is showing (#733), so the
* URL is always a permalink the user can copy, bookmark or hand to a dashboard.
*
* Always `replaceState`, never `pushState`: these surfaces have no popstate
* handler, and pushing entries the back button can't actually restore is worse
* than not pushing at all - the URL would change while the view sat still. Mail
* is the exception and drives its own history through `useBrowserNavigation`.
*
* Pass `null` to leave the URL alone (inside the Pro shell, where the route is
* /pro and the tab strip owns the address bar).
*
* The existing history state is preserved so Next's router bookkeeping - which
* lives in `window.history.state` - survives the rewrite.
*/
export function useDeepLinkUrl(path: string | null): void {
const lastRef = useRef<string | null>(null);
useEffect(() => {
if (typeof window === "undefined") return;
if (!path) return;
if (lastRef.current === path) return;
lastRef.current = path;
if (`${window.location.pathname}${window.location.search}` === path) return;
window.history.replaceState(window.history.state, "", path);
}, [path]);
}
+128
View File
@@ -0,0 +1,128 @@
'use client';
import { useCallback, useEffect, useRef, type KeyboardEvent, type RefObject } from 'react';
/**
* Explicit menu roles win over "any focusable child" so a menu that also holds
* secondary controls (e.g. the per-row remove button in the account switcher)
* keeps a predictable arrow order.
*/
const MENU_ITEM_SELECTOR =
'[role="menuitem"],[role="menuitemradio"],[role="menuitemcheckbox"],[role="option"]';
const FOCUSABLE_SELECTOR =
'button:not(:disabled),[href],input:not(:disabled),select:not(:disabled),textarea:not(:disabled),[tabindex]:not([tabindex="-1"])';
function isUsable(root: HTMLElement, el: HTMLElement): boolean {
if (el.hasAttribute('disabled') || el.getAttribute('aria-disabled') === 'true') return false;
if (typeof window === 'undefined' || typeof window.getComputedStyle !== 'function') return true;
// Toolbar overflow and responsive variants hide items with `display: none`
// on an ancestor; those must not become arrow-key targets.
for (let node: HTMLElement | null = el; node && node !== root.parentElement; node = node.parentElement) {
const style = window.getComputedStyle(node);
if (style.display === 'none' || style.visibility === 'hidden') return false;
}
return true;
}
interface UseMenuNavigationOptions {
open: boolean;
onClose: () => void;
/** The button that opens the menu. Focus returns here when the menu closes. */
triggerRef?: RefObject<HTMLElement | null>;
/** Move focus to the first item on open. Default true. */
autoFocus?: boolean;
}
/**
* Keyboard and focus behaviour for popup menus.
*
* Menus here are either portalled to `<body>` or absolutely positioned, so
* merely rendering them leaves screen reader and keyboard users stranded: the
* items end up far from the trigger in the reading order, which reads as
* "nothing happened". This moves focus into the menu on open, wires
* arrow/Home/End/Escape/Tab, and hands focus back to the trigger on close.
*/
export function useMenuNavigation<T extends HTMLElement = HTMLDivElement>({
open,
onClose,
triggerRef,
autoFocus = true,
}: UseMenuNavigationOptions) {
const menuRef = useRef<T>(null);
// Only pull focus back to the trigger if we were the ones who moved it away.
const focusMoved = useRef(false);
const getItems = useCallback((): HTMLElement[] => {
const menu = menuRef.current;
if (!menu) return [];
const explicit = Array.from(menu.querySelectorAll<HTMLElement>(MENU_ITEM_SELECTOR));
const candidates = explicit.length > 0
? explicit
: Array.from(menu.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
return candidates.filter((el) => isUsable(menu, el));
}, []);
useEffect(() => {
if (!open || !autoFocus) return;
// A frame of slack lets portals mount and positioning settle first.
const frame = requestAnimationFrame(() => {
const first = getItems()[0];
if (!first) return;
first.focus();
focusMoved.current = true;
});
return () => cancelAnimationFrame(frame);
}, [open, autoFocus, getItems]);
useEffect(() => {
if (open || !focusMoved.current) return;
focusMoved.current = false;
const trigger = triggerRef?.current;
if (!trigger || !document.contains(trigger)) return;
// Closing usually drops focus onto <body>; if something else has already
// claimed it (a dialog, a navigation) leave it alone.
const active = document.activeElement;
if (!active || active === document.body) trigger.focus();
}, [open, triggerRef]);
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
return;
}
if (e.key === 'Tab') {
// Tabbing out of a popup menu dismisses it, mirroring a click away.
onClose();
return;
}
const items = getItems();
if (items.length === 0) return;
const current = items.indexOf(document.activeElement as HTMLElement);
switch (e.key) {
case 'ArrowDown':
case 'ArrowUp': {
e.preventDefault();
const delta = e.key === 'ArrowDown' ? 1 : -1;
const next = current === -1
? (delta === 1 ? 0 : items.length - 1)
: (current + delta + items.length) % items.length;
items[next]?.focus();
break;
}
case 'Home':
e.preventDefault();
items[0]?.focus();
break;
case 'End':
e.preventDefault();
items[items.length - 1]?.focus();
break;
}
}, [getItems, onClose]);
return { menuRef, onKeyDown };
}
+294
View File
@@ -0,0 +1,294 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import {
buildFolderRef,
resolveFolderRef,
buildMailPath,
parseMailPath,
buildCalendarPath,
parseCalendarPath,
formatLinkDate,
parseLinkDate,
buildContactsPath,
parseContactsPath,
buildFilesPath,
parseFilesPath,
buildSettingsPath,
parseSettingsPath,
matchSurface,
SCHEDULED_MAILBOX_ID,
} from '@/lib/deep-links';
import {
UNIFIED_MAILBOX_IDS,
CROSS_VIEW_IDS,
type Mailbox,
} from '@/lib/jmap/types';
function mailbox(partial: Partial<Mailbox> & { id: string }): Mailbox {
return {
name: partial.id,
totalEmails: 0,
unreadEmails: 0,
...partial,
} as Mailbox;
}
const MAILBOXES: Mailbox[] = [
mailbox({ id: 'a', role: 'inbox', name: 'Inbox' }),
mailbox({ id: 'b', role: 'sent', name: 'Sent' }),
mailbox({ id: 'c', name: 'Projects/2026' }),
mailbox({ id: 'shared-a', role: 'inbox', name: 'Team Inbox', isShared: true, accountId: 'team' }),
];
// `appPath` bakes NEXT_PUBLIC_LOCALE_PREFIX / NEXT_PUBLIC_BASE_PATH at module
// load (mirroring how next.config.ts and i18n/routing.ts read them at build
// time), so prefix-mode cases have to re-import under a fresh env.
async function loadLinks(env: { localePrefix?: string; basePath?: string }) {
vi.resetModules();
if (env.localePrefix === undefined) delete process.env.NEXT_PUBLIC_LOCALE_PREFIX;
else process.env.NEXT_PUBLIC_LOCALE_PREFIX = env.localePrefix;
if (env.basePath === undefined) delete process.env.NEXT_PUBLIC_BASE_PATH;
else process.env.NEXT_PUBLIC_BASE_PATH = env.basePath;
return import('@/lib/deep-links');
}
afterEach(() => {
delete process.env.NEXT_PUBLIC_LOCALE_PREFIX;
delete process.env.NEXT_PUBLIC_BASE_PATH;
vi.resetModules();
});
describe('appPath — locale prefix and mount prefix', () => {
it('omits the locale in the default "never" mode', async () => {
const { appPath } = await loadLinks({});
expect(appPath('/mail/message/abc', 'de')).toBe('/mail/message/abc');
});
it('always prefixes the locale in "always" mode', async () => {
const { appPath } = await loadLinks({ localePrefix: 'always' });
expect(appPath('/mail/message/abc', 'de')).toBe('/de/mail/message/abc');
expect(appPath('/', 'de')).toBe('/de');
});
it('prefixes only non-default locales in "as-needed" mode', async () => {
const { appPath } = await loadLinks({ localePrefix: 'as-needed' });
expect(appPath('/calendar', 'en')).toBe('/calendar');
expect(appPath('/calendar', 'fr')).toBe('/fr/calendar');
});
it('applies the mount prefix on subpath deployments', async () => {
const { appPath } = await loadLinks({ localePrefix: 'always', basePath: '/webmail' });
expect(appPath('/mail/thread/t1', 'de')).toBe('/webmail/de/mail/thread/t1');
});
it('builds an absolute URL for copy-link', async () => {
const { appUrl } = await loadLinks({});
expect(appUrl('/mail/message/abc')).toBe(`${window.location.origin}/mail/message/abc`);
});
});
describe('mail folder references', () => {
it('uses the role name for the account\'s own well-known folders', () => {
expect(buildFolderRef('a', MAILBOXES)).toBe('inbox');
expect(buildFolderRef('b', MAILBOXES)).toBe('sent');
});
it('keeps the raw id for custom folders', () => {
expect(buildFolderRef('c', MAILBOXES)).toBe('c');
});
it('keeps the raw id for shared folders so they don\'t claim the role alias', () => {
expect(buildFolderRef('shared-a', MAILBOXES)).toBe('shared-a');
expect(resolveFolderRef('inbox', MAILBOXES)).toBe('a');
});
it('aliases the virtual unified, cross-account and scheduled views', () => {
expect(buildFolderRef(UNIFIED_MAILBOX_IDS.inbox)).toBe('unified-inbox');
expect(buildFolderRef(CROSS_VIEW_IDS.unread)).toBe('cross-unread');
expect(buildFolderRef(SCHEDULED_MAILBOX_ID)).toBe('scheduled');
expect(resolveFolderRef('unified-inbox')).toBe(UNIFIED_MAILBOX_IDS.inbox);
expect(resolveFolderRef('cross-unread')).toBe(CROSS_VIEW_IDS.unread);
expect(resolveFolderRef('scheduled')).toBe(SCHEDULED_MAILBOX_ID);
});
it('prefers an exact id match over an alias', () => {
const odd = [...MAILBOXES, mailbox({ id: 'inbox', name: 'A folder literally called inbox' })];
expect(resolveFolderRef('inbox', odd)).toBe('inbox');
});
it('returns null for a role that this account does not have', () => {
expect(resolveFolderRef('archive', MAILBOXES)).toBeNull();
});
});
describe('mail paths', () => {
it('round-trips folder, message and thread links', () => {
const folder = buildMailPath({ mailboxId: 'c', emailId: null, threadId: null }, MAILBOXES);
expect(folder).toBe('/mail/folder/c');
expect(parseMailPath(['folder', 'c'])).toEqual({ kind: 'folder', ref: 'c', accountId: undefined });
const message = buildMailPath({ mailboxId: 'a', emailId: 'm1', threadId: null }, MAILBOXES);
expect(message).toBe('/mail/message/m1');
expect(parseMailPath(['message', 'm1'])).toEqual({ kind: 'message', id: 'm1', accountId: undefined });
const thread = buildMailPath({ mailboxId: 'a', emailId: 'm1', threadId: 't1' }, MAILBOXES);
expect(thread).toBe('/mail/thread/t1');
expect(parseMailPath(['thread', 't1'])).toEqual({ kind: 'thread', id: 't1', accountId: undefined });
});
it('falls back to the bare mail path with nothing selected', () => {
expect(buildMailPath({ mailboxId: null, emailId: null, threadId: null })).toBe('/mail');
expect(parseMailPath([])).toBeNull();
});
it('percent-encodes ids that are not path-safe', () => {
const path = buildMailPath({ mailboxId: null, emailId: 'a/b c?d', threadId: null });
expect(path).toBe('/mail/message/a%2Fb%20c%3Fd');
const segment = path.split('/').pop()!;
expect(parseMailPath(['message', segment])).toEqual({
kind: 'message',
id: 'a/b c?d',
accountId: undefined,
});
});
it('survives a malformed percent-sequence instead of throwing', () => {
expect(parseMailPath(['message', '%E0%A4%A'])).toEqual({
kind: 'message',
id: '%E0%A4%A',
accountId: undefined,
});
});
it('carries the account disambiguator', () => {
const link = parseMailPath(['message', 'm1'], new URLSearchParams('account=acc-2'));
expect(link).toEqual({ kind: 'message', id: 'm1', accountId: 'acc-2' });
});
it('still understands the legacy ?email= notification link', () => {
expect(parseMailPath([], new URLSearchParams('email=m9'))).toEqual({
kind: 'message',
id: 'm9',
accountId: undefined,
});
});
it('ignores unknown segments rather than inventing an intent', () => {
expect(parseMailPath(['nonsense', 'x'])).toBeNull();
expect(parseMailPath(['message'])).toBeNull();
});
});
describe('calendar paths', () => {
it('formats and parses dates in local time', () => {
const date = new Date(2026, 7, 6);
expect(formatLinkDate(date)).toBe('2026-08-06');
expect(parseLinkDate('2026-08-06')?.getTime()).toBe(date.getTime());
});
it('rejects malformed and impossible dates', () => {
expect(parseLinkDate('2026-8-6')).toBeNull();
expect(parseLinkDate('2026-02-31')).toBeNull();
expect(parseLinkDate('nope')).toBeNull();
});
it('round-trips a view and date', () => {
const path = buildCalendarPath({ view: 'day', date: new Date(2026, 7, 6) });
expect(path).toBe('/calendar/day/2026-08-06');
const link = parseCalendarPath(['day', '2026-08-06']);
expect(link?.kind).toBe('view');
expect(link).toMatchObject({ view: 'day' });
});
it('round-trips an event, with the account that owns it', () => {
expect(buildCalendarPath({ view: 'month', date: null, eventId: 'e1' })).toBe('/calendar/event/e1');
expect(buildCalendarPath({ view: 'month', date: null, eventId: 'e1', accountId: 'acc-2' }))
.toBe('/calendar/event/e1?account=acc-2');
expect(parseCalendarPath(['event', 'e1'], new URLSearchParams('account=acc-2')))
.toEqual({ kind: 'event', id: 'e1', accountId: 'acc-2' });
});
it('accepts a bare date with no view', () => {
expect(parseCalendarPath(['2026-08-06'])).toMatchObject({ kind: 'view', view: 'month' });
});
it('returns null for an unknown view', () => {
expect(parseCalendarPath(['quarter'])).toBeNull();
expect(parseCalendarPath([])).toBeNull();
});
});
describe('contacts paths', () => {
it('round-trips detail and edit links', () => {
expect(buildContactsPath({ contactId: 'c1' })).toBe('/contacts/c1');
expect(buildContactsPath({ contactId: 'c1', editing: true })).toBe('/contacts/c1/edit');
expect(parseContactsPath(['c1'])).toEqual({ kind: 'contact', id: 'c1', edit: false, fromEmail: false });
expect(parseContactsPath(['c1', 'edit'])).toEqual({ kind: 'contact', id: 'c1', edit: true, fromEmail: false });
});
it('keeps the legacy query form working', () => {
expect(parseContactsPath([], new URLSearchParams('contactId=c1&view=edit&from=email')))
.toEqual({ kind: 'contact', id: 'c1', edit: true, fromEmail: true });
expect(parseContactsPath([], new URLSearchParams('addEmail=a%40b.c&addName=Ada')))
.toEqual({ kind: 'create', email: 'a@b.c', name: 'Ada', fromEmail: false });
});
it('handles the create link', () => {
expect(parseContactsPath(['new'], new URLSearchParams('email=a%40b.c')))
.toEqual({ kind: 'create', email: 'a@b.c', name: undefined, fromEmail: false });
});
});
describe('files paths', () => {
it('round-trips a nested folder', () => {
expect(buildFilesPath('/Documents/Invoices 2026')).toBe('/files/Documents/Invoices%202026');
expect(parseFilesPath(['Documents', 'Invoices%202026'])).toEqual({
path: '/Documents/Invoices 2026',
preview: undefined,
});
});
it('maps the drive root to the bare path', () => {
expect(buildFilesPath('/')).toBe('/files');
expect(parseFilesPath([])).toBeNull();
});
it('carries a file to preview', () => {
expect(buildFilesPath('/Documents', 'report.pdf')).toBe('/files/Documents?preview=report.pdf');
expect(parseFilesPath(['Documents'], new URLSearchParams('preview=report.pdf')))
.toEqual({ path: '/Documents', preview: 'report.pdf' });
});
});
describe('settings paths', () => {
it('round-trips a tab', () => {
expect(buildSettingsPath('appearance')).toBe('/settings/appearance');
expect(buildSettingsPath(null)).toBe('/settings');
expect(parseSettingsPath(['appearance'])).toBe('appearance');
expect(parseSettingsPath([])).toBeNull();
});
});
describe('matchSurface', () => {
it('maps the app root and /mail to the mail surface', () => {
expect(matchSurface('/')).toEqual({ surface: 'mail', segments: [] });
expect(matchSurface('/mail')).toEqual({ surface: 'mail', segments: [] });
expect(matchSurface('/mail/message/m1')).toEqual({ surface: 'mail', segments: ['message', 'm1'] });
});
it('ignores a leading locale segment', () => {
expect(matchSurface('/de/calendar/day/2026-08-06'))
.toEqual({ surface: 'calendar', segments: ['day', '2026-08-06'] });
expect(matchSurface('/de')).toEqual({ surface: 'mail', segments: [] });
});
it('strips query and hash', () => {
expect(matchSurface('/contacts/c1?from=email#x'))
.toEqual({ surface: 'contacts', segments: ['c1'] });
});
it('returns null for routes that are not app surfaces', () => {
expect(matchSurface('/pro')).toBeNull();
expect(matchSurface('/login')).toBeNull();
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { AppSurface } from '@/lib/deep-links';
/**
* One-shot handoff for a deep link that has to survive a route change (#733).
*
* The Pro shell owns a single route (`/pro`) and renders each surface inside a
* tab, so a permalink like `/mail/message/<id>` can't reach the surface as a
* route param: ProInterfaceRedirect intercepts it and replaces the URL. It
* parks the segments here, opens the right tab, and the surface picks them up
* on mount - exactly like `protocol-handlers/session` does for `mailto:`.
*
* Module state, deliberately: it must not survive a reload (the URL is gone by
* then, so re-applying it would reopen something the user already closed).
*/
const pending = new Map<AppSurface, string[]>();
export function setPendingDeepLink(surface: AppSurface, segments: string[]): void {
pending.set(surface, segments);
}
/** Returns the parked segments once, then forgets them. */
export function consumePendingDeepLink(surface: AppSurface): string[] | null {
const segments = pending.get(surface);
if (!segments) return null;
pending.delete(surface);
return segments;
}
export function clearPendingDeepLinks(): void {
pending.clear();
}
+432
View File
@@ -0,0 +1,432 @@
/**
* Deep links (permalinks) for every app surface.
*
* The app is a single client-side shell per surface, so the URL is not what
* mounts a view - it is a serialisation of the view the user is looking at.
* This module owns that serialisation in both directions:
*
* build* - view state → app-relative path (`/mail/message/abc`)
* parse* - route segments → a typed "intent" the surface applies on mount
*
* Paths produced here are *app-relative*: no locale prefix and no mount
* prefix. Use `appPath()` (or `appUrl()` for something copyable) to turn one
* into a real URL, so subpath deploys (NEXT_PUBLIC_BASE_PATH) and every
* NEXT_PUBLIC_LOCALE_PREFIX mode are handled in exactly one place.
*
* Nothing here throws or 404s on bad input: an unparseable link resolves to
* `null` and the surface falls back to its default view. Old links degrade,
* they don't break.
*/
import { routing } from '@/i18n/routing';
import { getLocaleFromPath, getPathPrefix, withBasePath } from '@/lib/browser-navigation';
import {
UNIFIED_MAILBOX_IDS,
UNIFIED_ROLE_BY_ID,
CROSS_VIEW_IDS,
CROSS_VIEW_BY_ID,
type Mailbox,
} from '@/lib/jmap/types';
import type { CalendarViewMode } from '@/stores/calendar-store';
/** Sentinel mailbox id for the virtual "Scheduled" view (see the mail page). */
export const SCHEDULED_MAILBOX_ID = '__scheduled__';
// ---------------------------------------------------------------------------
// URL assembly
// ---------------------------------------------------------------------------
/**
* Turns an app-relative path into one the browser can navigate to, applying
* the configured locale prefix and the deployment's mount prefix.
*
* appPath('/mail/message/abc') // prefix "never" → /mail/message/abc
* appPath('/mail/message/abc', 'de') // prefix "always" → /de/mail/message/abc
* appPath('/mail', 'en') // "as-needed", default locale → /mail
*
* `locale` defaults to the locale in the current URL, so callers in event
* handlers don't have to thread it through.
*/
export function appPath(path: string, locale?: string): string {
const normalized = path === '' ? '/' : path;
const resolved = locale ?? getLocaleFromPath();
let withLocale = normalized;
if (routing.localePrefix === 'always') {
withLocale = `/${resolved}${normalized === '/' ? '' : normalized}`;
} else if (routing.localePrefix === 'as-needed' && resolved !== routing.defaultLocale) {
withLocale = `/${resolved}${normalized === '/' ? '' : normalized}`;
}
return withBasePath(withLocale);
}
/**
* Absolute URL for an app-relative path - what "Copy link" puts on the
* clipboard. Falls back to the bare path during SSR, where there is no origin.
*/
export function appUrl(path: string, locale?: string): string {
const resolved = appPath(path, locale);
if (typeof window === 'undefined') return resolved;
return `${window.location.origin}${resolved}`;
}
/** Percent-encodes an opaque id for use as a single path segment. */
function encodeSegment(value: string): string {
return encodeURIComponent(value);
}
function decodeSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
// A malformed %-sequence is a broken link, not a crash: use it verbatim
// and let the surface's "not found" path handle it.
return value;
}
}
// ---------------------------------------------------------------------------
// Mail
// ---------------------------------------------------------------------------
/**
* Readable aliases for folders that have no stable server-side id: the virtual
* unified / cross-account views and the client-side Scheduled view.
*/
const VIRTUAL_FOLDER_ALIASES: Record<string, string> = {
scheduled: SCHEDULED_MAILBOX_ID,
'unified-inbox': UNIFIED_MAILBOX_IDS.inbox,
'unified-sent': UNIFIED_MAILBOX_IDS.sent,
'unified-drafts': UNIFIED_MAILBOX_IDS.drafts,
'unified-trash': UNIFIED_MAILBOX_IDS.trash,
'unified-archive': UNIFIED_MAILBOX_IDS.archive,
'unified-junk': UNIFIED_MAILBOX_IDS.junk,
'cross-unread': CROSS_VIEW_IDS.unread,
'cross-starred': CROSS_VIEW_IDS.starred,
'cross-all': CROSS_VIEW_IDS.all,
};
const VIRTUAL_ALIAS_BY_ID: Record<string, string> = Object.fromEntries(
Object.entries(VIRTUAL_FOLDER_ALIASES).map(([alias, id]) => [id, alias]),
);
/** Mailbox roles that get a readable alias instead of their opaque JMAP id. */
const ALIASED_ROLES = new Set(['inbox', 'sent', 'drafts', 'trash', 'archive', 'junk']);
export type MailDeepLink =
| { kind: 'folder'; ref: string; accountId?: string }
| { kind: 'message'; id: string; accountId?: string }
| { kind: 'thread'; id: string; accountId?: string };
export interface MailLinkState {
mailboxId: string | null;
emailId: string | null;
threadId: string | null;
}
/**
* The URL-facing reference for a mailbox: a virtual alias, a role name, or the
* raw JMAP id for custom folders (whose ids are the only thing that is stable).
*/
export function buildFolderRef(mailboxId: string, mailboxes: Mailbox[] = []): string {
const virtual = VIRTUAL_ALIAS_BY_ID[mailboxId];
if (virtual) return virtual;
const mailbox = mailboxes.find((m) => m.id === mailboxId);
// Shared folders carry another account's copy of a role (two "inbox" roles
// in one list), so only the account's own folders may claim the role alias.
if (mailbox?.role && ALIASED_ROLES.has(mailbox.role) && !mailbox.isShared) {
return mailbox.role;
}
return mailboxId;
}
/**
* Inverse of `buildFolderRef`. Returns null when the reference matches nothing
* in this account - a link to a folder that was deleted or belongs elsewhere.
*
* Exact ids are checked before aliases so a custom folder whose id happens to
* read like an alias still resolves to itself.
*/
export function resolveFolderRef(ref: string, mailboxes: Mailbox[] = []): string | null {
const decoded = decodeSegment(ref);
if (mailboxes.some((m) => m.id === decoded)) return decoded;
const virtual = VIRTUAL_FOLDER_ALIASES[decoded];
if (virtual) return virtual;
if (ALIASED_ROLES.has(decoded)) {
const byRole = mailboxes.find((m) => m.role === decoded && !m.isShared)
?? mailboxes.find((m) => m.role === decoded);
if (byRole) return byRole.id;
return null;
}
// Unknown id: the mailbox list may simply not be loaded yet, so hand it back
// and let the caller decide (it re-runs once mailboxes arrive).
return decoded || null;
}
/**
* The canonical path for the current mail view. Returns `/mail` for the bare
* list so the address bar stays clean when nothing is open.
*
* A thread wins over a single message: when the conversation view is open the
* thread is what the user is looking at.
*/
export function buildMailPath(state: MailLinkState, mailboxes: Mailbox[] = []): string {
if (state.threadId) return `/mail/thread/${encodeSegment(state.threadId)}`;
if (state.emailId) return `/mail/message/${encodeSegment(state.emailId)}`;
if (state.mailboxId) return `/mail/folder/${encodeSegment(buildFolderRef(state.mailboxId, mailboxes))}`;
return '/mail';
}
/**
* Parses the segments after `/mail`. Also accepts the legacy `?email=<id>`
* query the push service worker used to emit, so notifications from an older
* installed worker keep opening the right message.
*/
export function parseMailPath(
segments: string[] = [],
search?: URLSearchParams,
): MailDeepLink | null {
const accountId = search?.get('account') ?? undefined;
const [kind, value] = segments;
if (kind && value) {
const id = decodeSegment(value);
if (id) {
if (kind === 'message') return { kind: 'message', id, accountId };
if (kind === 'thread') return { kind: 'thread', id, accountId };
if (kind === 'folder') return { kind: 'folder', ref: id, accountId };
}
}
const legacyEmail = search?.get('email');
if (legacyEmail) return { kind: 'message', id: legacyEmail, accountId };
return null;
}
// ---------------------------------------------------------------------------
// Calendar
// ---------------------------------------------------------------------------
const CALENDAR_VIEWS: CalendarViewMode[] = ['month', 'week', 'day', 'agenda', 'tasks'];
export type CalendarDeepLink =
| { kind: 'event'; id: string; accountId?: string }
| { kind: 'view'; view: CalendarViewMode; date: Date | null };
export interface CalendarLinkState {
view: CalendarViewMode;
date: Date | null;
eventId?: string | null;
/** JMAP account owning the event - needed for shared/secondary calendars. */
accountId?: string | null;
}
/** `YYYY-MM-DD` in local time - the date the user sees, not a UTC shift of it. */
export function formatLinkDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
/** Parses `YYYY-MM-DD` as a local-midnight Date. Returns null if malformed. */
export function parseLinkDate(value: string): Date | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) return null;
const [, y, m, d] = match;
const date = new Date(Number(y), Number(m) - 1, Number(d));
if (Number.isNaN(date.getTime())) return null;
// Rejects impossible dates that Date silently rolls over (2026-02-31).
if (date.getMonth() !== Number(m) - 1 || date.getDate() !== Number(d)) return null;
return date;
}
export function buildCalendarPath(state: CalendarLinkState): string {
if (state.eventId) {
const path = `/calendar/event/${encodeSegment(state.eventId)}`;
return state.accountId ? `${path}?account=${encodeURIComponent(state.accountId)}` : path;
}
if (!state.date) return `/calendar/${state.view}`;
return `/calendar/${state.view}/${formatLinkDate(state.date)}`;
}
export function parseCalendarPath(
segments: string[] = [],
search?: URLSearchParams,
): CalendarDeepLink | null {
const [first, second] = segments;
if (!first) return null;
if (first === 'event') {
const id = second ? decodeSegment(second) : '';
if (!id) return null;
return { kind: 'event', id, accountId: search?.get('account') ?? undefined };
}
if ((CALENDAR_VIEWS as string[]).includes(first)) {
return {
kind: 'view',
view: first as CalendarViewMode,
date: second ? parseLinkDate(second) : null,
};
}
// A bare date with no view (`/calendar/2026-08-06`) keeps the user's view.
const date = parseLinkDate(first);
if (date) return { kind: 'view', view: 'month', date };
return null;
}
// ---------------------------------------------------------------------------
// Contacts
// ---------------------------------------------------------------------------
export type ContactDeepLink =
| { kind: 'contact'; id: string; edit: boolean; fromEmail: boolean }
| { kind: 'create'; email?: string; name?: string; fromEmail: boolean };
export interface ContactLinkState {
contactId: string | null;
editing?: boolean;
}
export function buildContactsPath(state: ContactLinkState): string {
if (!state.contactId) return '/contacts';
const path = `/contacts/${encodeSegment(state.contactId)}`;
return state.editing ? `${path}/edit` : path;
}
/**
* Parses `/contacts/<id>[/edit]` plus the legacy query form the email viewer
* used to build (`?contactId=`, `?addEmail=`, `?addName=`, `?from=email`).
* Both are still emitted by plugins and bookmarks, so both are supported.
*/
export function parseContactsPath(
segments: string[] = [],
search?: URLSearchParams,
): ContactDeepLink | null {
const fromEmail = search?.get('from') === 'email';
const [first, second] = segments;
if (first === 'new') {
return {
kind: 'create',
email: search?.get('email') ?? search?.get('addEmail') ?? undefined,
name: search?.get('name') ?? search?.get('addName') ?? undefined,
fromEmail,
};
}
if (first) {
const id = decodeSegment(first);
if (id) return { kind: 'contact', id, edit: second === 'edit', fromEmail };
}
const legacyId = search?.get('contactId');
if (legacyId) {
return { kind: 'contact', id: legacyId, edit: search?.get('view') === 'edit', fromEmail };
}
const addEmail = search?.get('addEmail');
const addName = search?.get('addName');
if (addEmail || addName) {
return { kind: 'create', email: addEmail ?? undefined, name: addName ?? undefined, fromEmail };
}
return null;
}
// ---------------------------------------------------------------------------
// Files
// ---------------------------------------------------------------------------
export interface FilesDeepLink {
/** Absolute folder path inside the drive, always leading-slashed. */
path: string;
/** File name to preview once the folder has loaded, if the link named one. */
preview?: string;
}
export function buildFilesPath(currentPath: string, preview?: string | null): string {
const parts = currentPath.split('/').filter(Boolean).map(encodeSegment);
const base = parts.length > 0 ? `/files/${parts.join('/')}` : '/files';
return preview ? `${base}?preview=${encodeURIComponent(preview)}` : base;
}
export function parseFilesPath(
segments: string[] = [],
search?: URLSearchParams,
): FilesDeepLink | null {
const parts = segments.map(decodeSegment).filter(Boolean);
const preview = search?.get('preview') ?? undefined;
if (parts.length === 0 && !preview) return null;
return { path: `/${parts.join('/')}`, preview };
}
// ---------------------------------------------------------------------------
// Settings
// ---------------------------------------------------------------------------
export function buildSettingsPath(tabId: string | null | undefined): string {
return tabId ? `/settings/${encodeSegment(tabId)}` : '/settings';
}
/**
* The tab id from `/settings/<tab>`. Validation against the real tab registry
* is the settings page's job - it owns the list and the legacy-id migration.
*/
export function parseSettingsPath(segments: string[] = []): string | null {
const [tab] = segments;
return tab ? decodeSegment(tab) : null;
}
// ---------------------------------------------------------------------------
// Surface routing (shared by the Pro shell and the login round-trip)
// ---------------------------------------------------------------------------
export type AppSurface = 'mail' | 'calendar' | 'contacts' | 'files' | 'settings';
/**
* Which surface an app-relative path belongs to, plus the segments after it.
* `/` and `/mail` both mean mail; anything unrecognised returns null.
*/
export function matchSurface(
path: string,
): { surface: AppSurface; segments: string[] } | null {
let clean = path.split('?')[0].split('#')[0];
// Accepts both app-relative paths and raw `window.location.pathname`, which
// carries the mount prefix on subpath deployments.
const prefix = getPathPrefix();
if (prefix && (clean === prefix || clean.startsWith(`${prefix}/`))) {
clean = clean.slice(prefix.length) || '/';
}
const parts = clean.split('/').filter(Boolean);
// Drop a leading locale segment so this works under every prefix mode.
if (parts.length > 0 && (routing.locales as readonly string[]).includes(parts[0])) {
parts.shift();
}
if (parts.length === 0) return { surface: 'mail', segments: [] };
const [head, ...rest] = parts;
if (head === 'mail') return { surface: 'mail', segments: rest };
if (head === 'calendar') return { surface: 'calendar', segments: rest };
if (head === 'contacts') return { surface: 'contacts', segments: rest };
if (head === 'files') return { surface: 'files', segments: rest };
if (head === 'settings') return { surface: 'settings', segments: rest };
return null;
}
/** Re-exported for callers that need to spot a virtual folder alias. */
export { UNIFIED_ROLE_BY_ID, CROSS_VIEW_BY_ID };
+4 -1
View File
@@ -1,5 +1,6 @@
import type { ParsedMailto } from "./mailto";
import type { ParsedWebcal } from "./webcal";
import { matchSurface } from "@/lib/deep-links";
const MAILTO_KEY = "bulwark:pending-mailto";
const WEBCAL_KEY = "bulwark:pending-webcal";
@@ -125,7 +126,9 @@ function createRequestId(): string {
const BROWSER_CLIENT_ID = createRequestId();
function getMailtoClientPriority(info: ProtocolClientInfo): number {
const isMailSection = info.path === "/" || info.path === "";
// The mail client's URL is a permalink now (#733), so "the mail section" is
// "/" plus every "/mail/..." deep link - not just the bare root.
const isMailSection = !info.path || matchSurface(info.path)?.surface === "mail";
if (info.standalone && isMailSection) return 0;
if (isMailSection) return 1;
if (info.standalone) return 2;
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "بحث",
"compose": "إنشاء رسالة",
"go_back": "العودة"
"go_back": "العودة",
"toggle_menu": "تبديل القائمة"
},
"clear_search": "مسح البحث",
"vacation_active": "الرد التلقائي أثناء الغياب مفعّل",
@@ -3326,5 +3327,18 @@
"install": "تثبيت",
"dont_remind": "عدم التذكير مرة أخرى",
"dismiss_aria": "تجاهل مطالبة التثبيت"
},
"deep_link": {
"message_not_found": "لم تعد هذه الرسالة متاحة.",
"thread_not_found": "لم تعد هذه المحادثة متاحة.",
"folder_not_found": "لم يعد هذا المجلد متاحًا.",
"event_not_found": "لم يعد هذا الحدث متاحًا.",
"file_not_found": "لم يعد هذا الملف متاحًا.",
"account_unavailable": "سجّل الدخول إلى ذلك الحساب لفتح هذا الرابط.",
"copy_message": "نسخ رابط الرسالة",
"copy_conversation": "نسخ رابط المحادثة",
"copy_event": "نسخ رابط الحدث",
"copied": "تم نسخ الرابط",
"copy_failed": "تعذّر نسخ الرابط"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Cerca",
"compose": "Redacta",
"go_back": "Torna enrere"
"go_back": "Torna enrere",
"toggle_menu": "Commuta el menú"
},
"clear_search": "Neteja la cerca",
"vacation_active": "La resposta automàtica està activa",
@@ -3326,5 +3327,18 @@
"install": "Instal·la",
"dont_remind": "No m'ho tornis a recordar",
"dismiss_aria": "Descarta l'avís d'instal·lació"
},
"deep_link": {
"message_not_found": "Aquest missatge ja no està disponible.",
"thread_not_found": "Aquesta conversa ja no està disponible.",
"folder_not_found": "Aquesta carpeta ja no està disponible.",
"event_not_found": "Aquest esdeveniment ja no està disponible.",
"file_not_found": "Aquest fitxer ja no està disponible.",
"account_unavailable": "Inicia la sessió en aquest compte per obrir l'enllaç.",
"copy_message": "Copia l'enllaç del missatge",
"copy_conversation": "Copia l'enllaç de la conversa",
"copy_event": "Copia l'enllaç de l'esdeveniment",
"copied": "Enllaç copiat",
"copy_failed": "No s'ha pogut copiar l'enllaç"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Hledat",
"compose": "Napsat",
"go_back": "Zpět"
"go_back": "Zpět",
"toggle_menu": "Přepnout nabídku"
},
"clear_search": "Vymazat hledání",
"vacation_active": "Automatická odpověď je aktivní",
@@ -3326,5 +3327,18 @@
"install": "Nainstalovat",
"dont_remind": "Už mi to nepřipomínat",
"dismiss_aria": "Zavřít výzvu k instalaci"
},
"deep_link": {
"message_not_found": "Tato zpráva už není dostupná.",
"thread_not_found": "Tato konverzace už není dostupná.",
"folder_not_found": "Tato složka už není dostupná.",
"event_not_found": "Tato událost už není dostupná.",
"file_not_found": "Tento soubor už není dostupný.",
"account_unavailable": "Přihlaste se k tomuto účtu, abyste odkaz otevřeli.",
"copy_message": "Kopírovat odkaz na zprávu",
"copy_conversation": "Kopírovat odkaz na konverzaci",
"copy_event": "Kopírovat odkaz na událost",
"copied": "Odkaz zkopírován",
"copy_failed": "Odkaz se nepodařilo zkopírovat"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Søg",
"compose": "Ny besked",
"go_back": "Gå tilbage"
"go_back": "Gå tilbage",
"toggle_menu": "Slå menu til/fra"
},
"clear_search": "Ryd søgning",
"vacation_active": "Fraværsbeskeder er aktive",
@@ -3326,5 +3327,18 @@
"install": "Installer",
"dont_remind": "Påmind mig ikke igen",
"dismiss_aria": "Afvis installationsprompt"
},
"deep_link": {
"message_not_found": "Denne besked er ikke længere tilgængelig.",
"thread_not_found": "Denne samtale er ikke længere tilgængelig.",
"folder_not_found": "Denne mappe er ikke længere tilgængelig.",
"event_not_found": "Denne begivenhed er ikke længere tilgængelig.",
"file_not_found": "Denne fil er ikke længere tilgængelig.",
"account_unavailable": "Log ind på den konto for at åbne dette link.",
"copy_message": "Kopier link til besked",
"copy_conversation": "Kopier link til samtale",
"copy_event": "Kopier link til begivenhed",
"copied": "Link kopieret",
"copy_failed": "Kunne ikke kopiere linket"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Suchen",
"compose": "Verfassen",
"go_back": "Zurück"
"go_back": "Zurück",
"toggle_menu": "Menü umschalten"
},
"clear_search": "Suche löschen",
"vacation_active": "Abwesenheitsnotiz ist aktiv",
@@ -3326,5 +3327,18 @@
"install": "Installieren",
"dont_remind": "Nicht mehr erinnern",
"dismiss_aria": "Installationshinweis schließen"
},
"deep_link": {
"message_not_found": "Diese Nachricht ist nicht mehr verfügbar.",
"thread_not_found": "Diese Unterhaltung ist nicht mehr verfügbar.",
"folder_not_found": "Dieser Ordner ist nicht mehr verfügbar.",
"event_not_found": "Dieser Termin ist nicht mehr verfügbar.",
"file_not_found": "Diese Datei ist nicht mehr verfügbar.",
"account_unavailable": "Melde dich bei diesem Konto an, um den Link zu öffnen.",
"copy_message": "Link zur Nachricht kopieren",
"copy_conversation": "Link zur Unterhaltung kopieren",
"copy_event": "Link zum Termin kopieren",
"copied": "Link kopiert",
"copy_failed": "Link konnte nicht kopiert werden"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Search",
"compose": "Compose",
"go_back": "Go back"
"go_back": "Go back",
"toggle_menu": "Toggle menu"
},
"clear_search": "Clear search",
"vacation_active": "Vacation responder is active",
@@ -3341,5 +3342,18 @@
"install": "Install",
"dont_remind": "Don't remind me again",
"dismiss_aria": "Dismiss install prompt"
},
"deep_link": {
"message_not_found": "This message is no longer available.",
"thread_not_found": "This conversation is no longer available.",
"folder_not_found": "This folder is no longer available.",
"event_not_found": "This event is no longer available.",
"file_not_found": "This file is no longer available.",
"account_unavailable": "Sign in to that account to open this link.",
"copy_message": "Copy link to message",
"copy_conversation": "Copy link to conversation",
"copy_event": "Copy link to event",
"copied": "Link copied",
"copy_failed": "Couldn't copy the link"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Buscar",
"compose": "Redactar",
"go_back": "Volver"
"go_back": "Volver",
"toggle_menu": "Alternar menú"
},
"clear_search": "Limpiar búsqueda",
"vacation_active": "Respuesta automática activa",
@@ -3326,5 +3327,18 @@
"install": "Instalar",
"dont_remind": "No volver a recordármelo",
"dismiss_aria": "Cerrar aviso de instalación"
},
"deep_link": {
"message_not_found": "Este mensaje ya no está disponible.",
"thread_not_found": "Esta conversación ya no está disponible.",
"folder_not_found": "Esta carpeta ya no está disponible.",
"event_not_found": "Este evento ya no está disponible.",
"file_not_found": "Este archivo ya no está disponible.",
"account_unavailable": "Inicia sesión en esa cuenta para abrir este enlace.",
"copy_message": "Copiar enlace del mensaje",
"copy_conversation": "Copiar enlace de la conversación",
"copy_event": "Copiar enlace del evento",
"copied": "Enlace copiado",
"copy_failed": "No se pudo copiar el enlace"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "جستجو",
"compose": "ایمیل جدید",
"go_back": "بازگشت"
"go_back": "بازگشت",
"toggle_menu": "تغییر وضعیت منو"
},
"clear_search": "پاک کردن جستجو",
"vacation_active": "پاسخ‌گوی خودکار فعال است",
@@ -3326,5 +3327,18 @@
"install": "نصب",
"dont_remind": "دیگر یادآوری نکن",
"dismiss_aria": "رد کردن پیشنهاد نصب"
},
"deep_link": {
"message_not_found": "این پیام دیگر در دسترس نیست.",
"thread_not_found": "این گفتگو دیگر در دسترس نیست.",
"folder_not_found": "این پوشه دیگر در دسترس نیست.",
"event_not_found": "این رویداد دیگر در دسترس نیست.",
"file_not_found": "این فایل دیگر در دسترس نیست.",
"account_unavailable": "برای باز کردن این پیوند، به آن حساب وارد شوید.",
"copy_message": "کپی پیوند پیام",
"copy_conversation": "کپی پیوند گفتگو",
"copy_event": "کپی پیوند رویداد",
"copied": "پیوند کپی شد",
"copy_failed": "کپی پیوند ممکن نشد"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Rechercher",
"compose": "Composer",
"go_back": "Retour"
"go_back": "Retour",
"toggle_menu": "Afficher/masquer le menu"
},
"clear_search": "Effacer la recherche",
"vacation_active": "Répondeur d'absence activé",
@@ -3326,5 +3327,18 @@
"install": "Installer",
"dont_remind": "Ne plus me le rappeler",
"dismiss_aria": "Fermer l'invite d'installation"
},
"deep_link": {
"message_not_found": "Ce message n'est plus disponible.",
"thread_not_found": "Cette conversation n'est plus disponible.",
"folder_not_found": "Ce dossier n'est plus disponible.",
"event_not_found": "Cet événement n'est plus disponible.",
"file_not_found": "Ce fichier n'est plus disponible.",
"account_unavailable": "Connectez-vous à ce compte pour ouvrir ce lien.",
"copy_message": "Copier le lien du message",
"copy_conversation": "Copier le lien de la conversation",
"copy_event": "Copier le lien de l'événement",
"copied": "Lien copié",
"copy_failed": "Impossible de copier le lien"
}
}
+15 -1
View File
@@ -114,7 +114,8 @@
"mobile": {
"search": "לְחַפֵּשׂ",
"compose": "לְהַלחִין",
"go_back": "לַחֲזוֹר"
"go_back": "לַחֲזוֹר",
"toggle_menu": "החלף תפריט"
},
"clear_search": "נקה חיפוש",
"vacation_active": "משיב חופשה פעיל",
@@ -3327,5 +3328,18 @@
"install": "התקן",
"dont_remind": "אל תזכיר לי שוב",
"dismiss_aria": "בטל הודעת התקנה"
},
"deep_link": {
"message_not_found": "הודעה זו אינה זמינה עוד.",
"thread_not_found": "שיחה זו אינה זמינה עוד.",
"folder_not_found": "תיקייה זו אינה זמינה עוד.",
"event_not_found": "אירוע זה אינו זמין עוד.",
"file_not_found": "קובץ זה אינו זמין עוד.",
"account_unavailable": "היכנס לחשבון הזה כדי לפתוח את הקישור.",
"copy_message": "העתק קישור להודעה",
"copy_conversation": "העתק קישור לשיחה",
"copy_event": "העתק קישור לאירוע",
"copied": "הקישור הועתק",
"copy_failed": "לא ניתן להעתיק את הקישור"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Keresés",
"compose": "Új levél",
"go_back": "Vissza"
"go_back": "Vissza",
"toggle_menu": "Menü be/ki"
},
"clear_search": "Keresés törlése",
"vacation_active": "Automatikus válasz aktív",
@@ -3326,5 +3327,18 @@
"install": "Telepítés",
"dont_remind": "Ne emlékeztess többet",
"dismiss_aria": "Telepítési ablak elutasítása"
},
"deep_link": {
"message_not_found": "Ez az üzenet már nem érhető el.",
"thread_not_found": "Ez a beszélgetés már nem érhető el.",
"folder_not_found": "Ez a mappa már nem érhető el.",
"event_not_found": "Ez az esemény már nem érhető el.",
"file_not_found": "Ez a fájl már nem érhető el.",
"account_unavailable": "Jelentkezz be ebbe a fiókba a link megnyitásához.",
"copy_message": "Üzenet linkjének másolása",
"copy_conversation": "Beszélgetés linkjének másolása",
"copy_event": "Esemény linkjének másolása",
"copied": "Link másolva",
"copy_failed": "Nem sikerült másolni a linket"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Cerca",
"compose": "Scrivi",
"go_back": "Indietro"
"go_back": "Indietro",
"toggle_menu": "Attiva/disattiva menu"
},
"clear_search": "Cancella ricerca",
"vacation_active": "Risponditore automatico attivo",
@@ -3326,5 +3327,18 @@
"install": "Installa",
"dont_remind": "Non ricordarmelo più",
"dismiss_aria": "Chiudi avviso di installazione"
},
"deep_link": {
"message_not_found": "Questo messaggio non è più disponibile.",
"thread_not_found": "Questa conversazione non è più disponibile.",
"folder_not_found": "Questa cartella non è più disponibile.",
"event_not_found": "Questo evento non è più disponibile.",
"file_not_found": "Questo file non è più disponibile.",
"account_unavailable": "Accedi a quell'account per aprire questo link.",
"copy_message": "Copia link al messaggio",
"copy_conversation": "Copia link alla conversazione",
"copy_event": "Copia link all'evento",
"copied": "Link copiato",
"copy_failed": "Impossibile copiare il link"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "検索",
"compose": "作成",
"go_back": "戻る"
"go_back": "戻る",
"toggle_menu": "メニューを切り替え"
},
"clear_search": "検索をクリア",
"vacation_active": "不在応答が有効です",
@@ -3326,5 +3327,18 @@
"install": "インストール",
"dont_remind": "今後表示しない",
"dismiss_aria": "インストールプロンプトを閉じる"
},
"deep_link": {
"message_not_found": "このメッセージは利用できなくなりました。",
"thread_not_found": "この会話は利用できなくなりました。",
"folder_not_found": "このフォルダーは利用できなくなりました。",
"event_not_found": "この予定は利用できなくなりました。",
"file_not_found": "このファイルは利用できなくなりました。",
"account_unavailable": "このリンクを開くには、そのアカウントにサインインしてください。",
"copy_message": "メッセージのリンクをコピー",
"copy_conversation": "会話のリンクをコピー",
"copy_event": "予定のリンクをコピー",
"copied": "リンクをコピーしました",
"copy_failed": "リンクをコピーできませんでした"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "검색",
"compose": "메일 쓰기",
"go_back": "뒤로 가기"
"go_back": "뒤로 가기",
"toggle_menu": "메뉴 전환"
},
"clear_search": "검색어 지우기",
"vacation_active": "부재중 자동 응답이 켜져 있어요",
@@ -3326,5 +3327,18 @@
"install": "설치",
"dont_remind": "다시 알리지 않음",
"dismiss_aria": "설치 프롬프트 닫기"
},
"deep_link": {
"message_not_found": "이 메시지를 더 이상 사용할 수 없습니다.",
"thread_not_found": "이 대화를 더 이상 사용할 수 없습니다.",
"folder_not_found": "이 폴더를 더 이상 사용할 수 없습니다.",
"event_not_found": "이 일정을 더 이상 사용할 수 없습니다.",
"file_not_found": "이 파일을 더 이상 사용할 수 없습니다.",
"account_unavailable": "이 링크를 열려면 해당 계정으로 로그인하세요.",
"copy_message": "메시지 링크 복사",
"copy_conversation": "대화 링크 복사",
"copy_event": "일정 링크 복사",
"copied": "링크가 복사되었습니다",
"copy_failed": "링크를 복사하지 못했습니다"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Meklēt",
"compose": "Rakstīt",
"go_back": "Atpakaļ"
"go_back": "Atpakaļ",
"toggle_menu": "Pārslēgt izvēlni"
},
"clear_search": "Notīrīt meklēšanu",
"vacation_active": "Atvaļinājuma atbilde ir aktīva",
@@ -3326,5 +3327,18 @@
"install": "Instalēt",
"dont_remind": "Vairs man neatgādināt",
"dismiss_aria": "Aizvērt instalēšanas paziņojumu"
},
"deep_link": {
"message_not_found": "Šī ziņa vairs nav pieejama.",
"thread_not_found": "Šī sarakste vairs nav pieejama.",
"folder_not_found": "Šī mape vairs nav pieejama.",
"event_not_found": "Šis notikums vairs nav pieejams.",
"file_not_found": "Šis fails vairs nav pieejams.",
"account_unavailable": "Piesakieties šajā kontā, lai atvērtu saiti.",
"copy_message": "Kopēt saiti uz ziņu",
"copy_conversation": "Kopēt saiti uz saraksti",
"copy_event": "Kopēt saiti uz notikumu",
"copied": "Saite nokopēta",
"copy_failed": "Neizdevās nokopēt saiti"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Zoeken",
"compose": "Opstellen",
"go_back": "Terug"
"go_back": "Terug",
"toggle_menu": "Menu wisselen"
},
"clear_search": "Zoekopdracht wissen",
"vacation_active": "Afwezigheidsmelder is actief",
@@ -3326,5 +3327,18 @@
"install": "Installeren",
"dont_remind": "Niet meer herinneren",
"dismiss_aria": "Installatiemelding sluiten"
},
"deep_link": {
"message_not_found": "Dit bericht is niet meer beschikbaar.",
"thread_not_found": "Dit gesprek is niet meer beschikbaar.",
"folder_not_found": "Deze map is niet meer beschikbaar.",
"event_not_found": "Deze afspraak is niet meer beschikbaar.",
"file_not_found": "Dit bestand is niet meer beschikbaar.",
"account_unavailable": "Log in op dat account om deze link te openen.",
"copy_message": "Link naar bericht kopiëren",
"copy_conversation": "Link naar gesprek kopiëren",
"copy_event": "Link naar afspraak kopiëren",
"copied": "Link gekopieerd",
"copy_failed": "Kon de link niet kopiëren"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Szukaj",
"compose": "Napisz",
"go_back": "Wróć"
"go_back": "Wróć",
"toggle_menu": "Przełącz menu"
},
"clear_search": "Wyczyść wyszukiwanie",
"vacation_active": "Autoresponder wakacyjny jest aktywny",
@@ -3326,5 +3327,18 @@
"install": "Zainstaluj",
"dont_remind": "Nie przypominaj mi więcej",
"dismiss_aria": "Zamknij monit instalacji"
},
"deep_link": {
"message_not_found": "Ta wiadomość nie jest już dostępna.",
"thread_not_found": "Ta rozmowa nie jest już dostępna.",
"folder_not_found": "Ten folder nie jest już dostępny.",
"event_not_found": "To wydarzenie nie jest już dostępne.",
"file_not_found": "Ten plik nie jest już dostępny.",
"account_unavailable": "Zaloguj się na to konto, aby otworzyć ten link.",
"copy_message": "Kopiuj link do wiadomości",
"copy_conversation": "Kopiuj link do rozmowy",
"copy_event": "Kopiuj link do wydarzenia",
"copied": "Link skopiowany",
"copy_failed": "Nie udało się skopiować linku"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Buscar",
"compose": "Escrever",
"go_back": "Voltar"
"go_back": "Voltar",
"toggle_menu": "Alternar menu"
},
"clear_search": "Limpar busca",
"vacation_active": "Resposta automática ativa",
@@ -3326,5 +3327,18 @@
"install": "Instalar",
"dont_remind": "Não lembrar novamente",
"dismiss_aria": "Dispensar aviso de instalação"
},
"deep_link": {
"message_not_found": "Esta mensagem não está mais disponível.",
"thread_not_found": "Esta conversa não está mais disponível.",
"folder_not_found": "Esta pasta não está mais disponível.",
"event_not_found": "Este evento não está mais disponível.",
"file_not_found": "Este arquivo não está mais disponível.",
"account_unavailable": "Entre nessa conta para abrir este link.",
"copy_message": "Copiar link da mensagem",
"copy_conversation": "Copiar link da conversa",
"copy_event": "Copiar link do evento",
"copied": "Link copiado",
"copy_failed": "Não foi possível copiar o link"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Căutare",
"compose": "Compune",
"go_back": "Înapoi"
"go_back": "Înapoi",
"toggle_menu": "Comută meniul"
},
"clear_search": "Șterge căutarea",
"vacation_active": "Răspunsul automat de vacanță este activ",
@@ -3326,5 +3327,18 @@
"install": "Instalați",
"dont_remind": "Nu-mi mai reaminti",
"dismiss_aria": "Ignorați solicitarea de instalare"
},
"deep_link": {
"message_not_found": "Acest mesaj nu mai este disponibil.",
"thread_not_found": "Această conversație nu mai este disponibilă.",
"folder_not_found": "Acest folder nu mai este disponibil.",
"event_not_found": "Acest eveniment nu mai este disponibil.",
"file_not_found": "Acest fișier nu mai este disponibil.",
"account_unavailable": "Conectați-vă la acel cont pentru a deschide linkul.",
"copy_message": "Copiați linkul mesajului",
"copy_conversation": "Copiați linkul conversației",
"copy_event": "Copiați linkul evenimentului",
"copied": "Link copiat",
"copy_failed": "Linkul nu a putut fi copiat"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Поиск",
"compose": "Создать",
"go_back": "Назад"
"go_back": "Назад",
"toggle_menu": "Переключить меню"
},
"clear_search": "Очистить поиск",
"vacation_active": "Автоответ на отпуск активен",
@@ -3326,5 +3327,18 @@
"install": "Установить",
"dont_remind": "Больше не напоминать",
"dismiss_aria": "Закрыть запрос на установку"
},
"deep_link": {
"message_not_found": "Это письмо больше недоступно.",
"thread_not_found": "Эта переписка больше недоступна.",
"folder_not_found": "Эта папка больше недоступна.",
"event_not_found": "Это событие больше недоступно.",
"file_not_found": "Этот файл больше недоступен.",
"account_unavailable": "Войдите в эту учётную запись, чтобы открыть ссылку.",
"copy_message": "Скопировать ссылку на письмо",
"copy_conversation": "Скопировать ссылку на переписку",
"copy_event": "Скопировать ссылку на событие",
"copied": "Ссылка скопирована",
"copy_failed": "Не удалось скопировать ссылку"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Hľadať",
"compose": "Napísať",
"go_back": "Späť"
"go_back": "Späť",
"toggle_menu": "Prepnúť ponuku"
},
"clear_search": "Vymazať hľadanie",
"vacation_active": "Automatická odpoveď je aktívna",
@@ -3326,5 +3327,18 @@
"install": "Nainštalovať",
"dont_remind": "Viac mi to nepripomínať",
"dismiss_aria": "Zavrieť výzvu na inštaláciu"
},
"deep_link": {
"message_not_found": "Táto správa už nie je dostupná.",
"thread_not_found": "Táto konverzácia už nie je dostupná.",
"folder_not_found": "Tento priečinok už nie je dostupný.",
"event_not_found": "Táto udalosť už nie je dostupná.",
"file_not_found": "Tento súbor už nie je dostupný.",
"account_unavailable": "Prihláste sa do tohto účtu, aby ste odkaz otvorili.",
"copy_message": "Kopírovať odkaz na správu",
"copy_conversation": "Kopírovať odkaz na konverzáciu",
"copy_event": "Kopírovať odkaz na udalosť",
"copied": "Odkaz skopírovaný",
"copy_failed": "Odkaz sa nepodarilo skopírovať"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Ara",
"compose": "Yeni E-posta",
"go_back": "Geri git"
"go_back": "Geri git",
"toggle_menu": "Menüyü aç/kapat"
},
"clear_search": "Aramayı temizle",
"vacation_active": "Tatil otomatik yanıtı aktif",
@@ -3326,5 +3327,18 @@
"install": "Yükle",
"dont_remind": "Bir daha hatırlatma",
"dismiss_aria": "Yükleme istemini kapat"
},
"deep_link": {
"message_not_found": "Bu ileti artık kullanılamıyor.",
"thread_not_found": "Bu sohbet artık kullanılamıyor.",
"folder_not_found": "Bu klasör artık kullanılamıyor.",
"event_not_found": "Bu etkinlik artık kullanılamıyor.",
"file_not_found": "Bu dosya artık kullanılamıyor.",
"account_unavailable": "Bu bağlantıyı açmak için o hesapta oturum açın.",
"copy_message": "İleti bağlantısını kopyala",
"copy_conversation": "Sohbet bağlantısını kopyala",
"copy_event": "Etkinlik bağlantısını kopyala",
"copied": "Bağlantı kopyalandı",
"copy_failed": "Bağlantı kopyalanamadı"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "Пошук",
"compose": "Скласти",
"go_back": "Іди назад"
"go_back": "Іди назад",
"toggle_menu": "Перемкнути меню"
},
"clear_search": "Очистити пошук",
"vacation_active": "Автовідповідач активний",
@@ -3326,5 +3327,18 @@
"install": "Встановити",
"dont_remind": "Більше не нагадувати",
"dismiss_aria": "Закрити запит на встановлення"
},
"deep_link": {
"message_not_found": "Цей лист більше недоступний.",
"thread_not_found": "Ця розмова більше недоступна.",
"folder_not_found": "Ця тека більше недоступна.",
"event_not_found": "Ця подія більше недоступна.",
"file_not_found": "Цей файл більше недоступний.",
"account_unavailable": "Увійдіть у цей обліковий запис, щоб відкрити посилання.",
"copy_message": "Скопіювати посилання на лист",
"copy_conversation": "Скопіювати посилання на розмову",
"copy_event": "Скопіювати посилання на подію",
"copied": "Посилання скопійовано",
"copy_failed": "Не вдалося скопіювати посилання"
}
}
+15 -1
View File
@@ -122,7 +122,8 @@
"mobile": {
"search": "搜索",
"compose": "撰写邮件",
"go_back": "返回"
"go_back": "返回",
"toggle_menu": "切换菜单"
},
"clear_search": "清除搜索",
"vacation_active": "假期自动回复已开启",
@@ -3326,5 +3327,18 @@
"install": "安装",
"dont_remind": "不再提醒",
"dismiss_aria": "关闭安装提示"
},
"deep_link": {
"message_not_found": "此邮件已不可用。",
"thread_not_found": "此会话已不可用。",
"folder_not_found": "此文件夹已不可用。",
"event_not_found": "此日程已不可用。",
"file_not_found": "此文件已不可用。",
"account_unavailable": "请登录该账户以打开此链接。",
"copy_message": "复制邮件链接",
"copy_conversation": "复制会话链接",
"copy_event": "复制日程链接",
"copied": "链接已复制",
"copy_failed": "无法复制链接"
}
}
+26 -2
View File
@@ -322,10 +322,32 @@ async function findReusableWindowClientEntry(sourceClientId, requireMailtoReady)
return candidates[0];
}
/**
* Whether a client's path is the mail section. Since #733 the mail client
* keeps a permalink in the address bar, so an open inbox reads as
* `/mail/folder/inbox` (optionally behind a mount prefix and a locale
* segment) rather than a bare "/".
*
* Deliberately duplicated from lib/deep-links.ts: this file is served raw, not
* bundled, so it cannot import from the app. The locale segment is matched by
* shape (two lowercase letters) because the worker has no locale list - and no
* app route is a bare two-letter segment.
*/
function isMailSectionPath(path) {
if (!path) return true;
let rest = path;
if (BASE_PATH && (rest === BASE_PATH || rest.startsWith(`${BASE_PATH}/`))) {
rest = rest.slice(BASE_PATH.length);
}
const segments = rest.split("/").filter(Boolean);
if (segments.length > 0 && /^[a-z]{2}$/.test(segments[0])) segments.shift();
return segments.length === 0 || segments[0] === "mail";
}
function getReusableClientScore(state) {
if (!state) return 4;
const isMailSection = state.path === "/" || state.path === "";
const isMailSection = isMailSectionPath(state.path);
if (state.standalone && isMailSection) return 0;
if (isMailSection) return 1;
if (state.standalone) return 2;
@@ -335,7 +357,9 @@ function getReusableClientScore(state) {
function buildClickUrl(data) {
if (!data) return `${BASE_PATH}/`;
if (data.kind === "email" && data.emailId) {
return `${BASE_PATH}/?email=${encodeURIComponent(data.emailId)}`;
// Permalink (#733). Under NEXT_PUBLIC_LOCALE_PREFIX=always the proxy
// redirects this to the localised path; the worker has no locale to add.
return `${BASE_PATH}/mail/message/${encodeURIComponent(data.emailId)}`;
}
// Generic "New mail" toast (preview API failed or returned no email): land
// the user on the latest unread message in their Inbox rather than just the
+6 -1
View File
@@ -281,7 +281,12 @@ function getLocaleLoginPath(): string {
return `${prefix}/${locale}/login`;
}
function saveRedirectAfterLogin(): void {
/**
* Remembers where the user was so login can send them back. Stores the query
* and hash too, not just the path - a deep link's disambiguators (#733) live
* there, and dropping them silently lands the user on the wrong thing.
*/
export function saveRedirectAfterLogin(): void {
if (typeof window === 'undefined') return;
try {