mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
feat: deep links for mail, calendar, contacts, files, settings and improve screen reader support
#733 #722 #721 #720 #719
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 ?? []} />;
|
||||
}
|
||||
@@ -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 ?? []} />;
|
||||
}
|
||||
+5
-3725
File diff suppressed because it is too large
Load Diff
@@ -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 ?? []} />;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'}`}
|
||||
>
|
||||
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user