From e2da05c130c34cea2c41b7e526621038be297cbe Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:47:20 +0200 Subject: [PATCH] feat: deep links for mail, calendar, contacts, files, settings and improve screen reader support #733 #722 #721 #720 #719 --- FEATURES.md | 1 + app/(main)/[locale]/auth/callback/page.tsx | 9 +- .../calendar/[[...segments]]/page.tsx | 15 + .../contacts/[[...segments]]/page.tsx | 13 + .../[locale]/files/[[...segments]]/page.tsx | 12 + app/(main)/[locale]/login/page.tsx | 30 +- .../[locale]/mail/[[...segments]]/page.tsx | 20 + app/(main)/[locale]/page.tsx | 3730 +--------------- app/(main)/[locale]/pro/page.tsx | 23 +- .../settings/[[...segments]]/page.tsx | 12 + app/(main)/admin/_tabs/auth.tsx | 3 +- app/(main)/admin/_tabs/plugins.tsx | 9 +- app/(main)/admin/_tabs/policy.tsx | 3 +- app/(main)/admin/_tabs/settings.tsx | 4 + app/(main)/admin/_tabs/themes.tsx | 12 +- .../calendar/calendar-app.tsx | 68 +- components/calendar/event-context-menu.tsx | 19 + .../contacts/contacts-app.tsx | 61 +- components/email/email-context-menu.tsx | 30 + components/email/email-viewer.tsx | 149 +- .../files/files-app.tsx | 108 +- components/layout/account-switcher.tsx | 24 +- components/layout/mobile-header.tsx | 7 +- components/layout/navigation-rail.tsx | 17 +- components/layout/sidebar.tsx | 14 +- components/mail/mail-app.tsx | 3903 +++++++++++++++++ components/pro/pro-interface-redirect.tsx | 39 +- .../__tests__/settings-section.test.tsx | 61 + .../settings/settings-app.tsx | 53 +- components/settings/settings-section.tsx | 31 +- components/settings/sidebar-apps-settings.tsx | 3 + components/ui/language-switcher.tsx | 56 +- .../__tests__/use-browser-navigation.test.tsx | 100 + hooks/__tests__/use-menu-navigation.test.tsx | 109 + hooks/use-browser-navigation.ts | 28 +- hooks/use-copy-link.ts | 27 + hooks/use-deep-link-url.ts | 31 + hooks/use-menu-navigation.ts | 128 + lib/__tests__/deep-links.test.ts | 294 ++ lib/deep-link-handoff.ts | 31 + lib/deep-links.ts | 432 ++ lib/protocol-handlers/session.ts | 5 +- locales/ar/common.json | 16 +- locales/ca/common.json | 16 +- locales/cs/common.json | 16 +- locales/da/common.json | 16 +- locales/de/common.json | 16 +- locales/en/common.json | 16 +- locales/es/common.json | 16 +- locales/fa/common.json | 16 +- locales/fr/common.json | 16 +- locales/he/common.json | 16 +- locales/hu/common.json | 16 +- locales/it/common.json | 16 +- locales/ja/common.json | 16 +- locales/ko/common.json | 16 +- locales/lv/common.json | 16 +- locales/nl/common.json | 16 +- locales/pl/common.json | 16 +- locales/pt/common.json | 16 +- locales/ro/common.json | 16 +- locales/ru/common.json | 16 +- locales/sk/common.json | 16 +- locales/tr/common.json | 16 +- locales/uk/common.json | 16 +- locales/zh/common.json | 16 +- public/sw.js | 28 +- stores/auth-store.ts | 7 +- 68 files changed, 6248 insertions(+), 3895 deletions(-) create mode 100644 app/(main)/[locale]/calendar/[[...segments]]/page.tsx create mode 100644 app/(main)/[locale]/contacts/[[...segments]]/page.tsx create mode 100644 app/(main)/[locale]/files/[[...segments]]/page.tsx create mode 100644 app/(main)/[locale]/mail/[[...segments]]/page.tsx create mode 100644 app/(main)/[locale]/settings/[[...segments]]/page.tsx rename app/(main)/[locale]/calendar/page.tsx => components/calendar/calendar-app.tsx (96%) rename app/(main)/[locale]/contacts/page.tsx => components/contacts/contacts-app.tsx (95%) rename app/(main)/[locale]/files/page.tsx => components/files/files-app.tsx (84%) create mode 100644 components/mail/mail-app.tsx create mode 100644 components/settings/__tests__/settings-section.test.tsx rename app/(main)/[locale]/settings/page.tsx => components/settings/settings-app.tsx (95%) create mode 100644 hooks/__tests__/use-browser-navigation.test.tsx create mode 100644 hooks/__tests__/use-menu-navigation.test.tsx create mode 100644 hooks/use-copy-link.ts create mode 100644 hooks/use-deep-link-url.ts create mode 100644 hooks/use-menu-navigation.ts create mode 100644 lib/__tests__/deep-links.test.ts create mode 100644 lib/deep-link-handoff.ts create mode 100644 lib/deep-links.ts diff --git a/FEATURES.md b/FEATURES.md index 2ae105f0a..7e4f84bb9 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -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/`, `/mail/thread/`, `/mail/folder/inbox`, `/calendar/day/2026-08-06`, `/calendar/event/`, `/contacts/`, `/files/`, and `/settings/` 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 diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index d994fda80..a52a17ead 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -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"); } diff --git a/app/(main)/[locale]/calendar/[[...segments]]/page.tsx b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx new file mode 100644 index 000000000..2a8cb8a5c --- /dev/null +++ b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx @@ -0,0 +1,15 @@ +import { CalendarApp } from "@/components/calendar/calendar-app"; + +// Calendar permalinks (#733): +// /calendar the stored view, today +// /calendar// a view anchored on a date +// /calendar/event/ one event, with ?calendar= to +// disambiguate across accounts +export default async function CalendarRoute({ + params, +}: { + params: Promise<{ segments?: string[] }>; +}) { + const { segments } = await params; + return ; +} diff --git a/app/(main)/[locale]/contacts/[[...segments]]/page.tsx b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx new file mode 100644 index 000000000..4adf80a48 --- /dev/null +++ b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx @@ -0,0 +1,13 @@ +import { ContactsApp } from "@/components/contacts/contacts-app"; + +// Contact permalinks (#733): /contacts/[/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 ; +} diff --git a/app/(main)/[locale]/files/[[...segments]]/page.tsx b/app/(main)/[locale]/files/[[...segments]]/page.tsx new file mode 100644 index 000000000..810b38d15 --- /dev/null +++ b/app/(main)/[locale]/files/[[...segments]]/page.tsx @@ -0,0 +1,12 @@ +import { FilesApp } from "@/components/files/files-app"; + +// Files permalinks (#733): /files//, one path segment per +// level, with ?preview= to open a file straight into the preview modal. +export default async function FilesRoute({ + params, +}: { + params: Promise<{ segments?: string[] }>; +}) { + const { segments } = await params; + return ; +} diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 296b7c07b..01fbed66f 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -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(null); const prevError = useRef(null); const themeMenuRef = useRef(null); + const themeButtonRef = useRef(null); + const closeThemeMenu = useCallback(() => setShowThemeMenu(false), []); + const { menuRef: themeListRef, onKeyDown: onThemeMenuKeyDown } = useMenuNavigation({ + 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() {