From 3f444a8912ee71a4005b7eebdd2ca90f7ea7d0ba Mon Sep 17 00:00:00 2001 From: Lucas Gaitzsch Date: Tue, 12 May 2026 19:56:44 +0200 Subject: [PATCH] Feature/protocol handlers * Added account selection for protocol links when multiple connected accounts are available, including mailto: links * Added support for handling mailto: links in an already-open PWA/session instead of always opening a new tab * Added webcal: protocol handling for calendar links * Added account selection for webcal: links when multiple calendar-capable accounts are connected * Added an import-or-subscribe choice for detected webcal calendars * Added protocol handler settings for registering mail and calendar handlers and choosing the open mode * Added service worker/session coordination for passing protocol requests between browser/PWA contexts * Added tests and translations for the new protocol handler flows --- app/[locale]/calendar/page.tsx | 157 +++++++- app/[locale]/layout.tsx | 5 +- app/[locale]/page.tsx | 103 ++++- app/[locale]/settings/page.tsx | 8 + app/manifest.ts | 22 +- app/protocol/mailto/page.tsx | 8 + app/protocol/webcal/page.tsx | 8 + components/calendar/ical-import-modal.tsx | 7 +- .../calendar/ical-subscription-modal.tsx | 8 +- .../protocol/mailto-protocol-client.tsx | 107 ++++++ .../protocol/protocol-account-picker.tsx | 161 ++++++++ .../protocol-launch-handler-provider.tsx | 133 +++++++ .../protocol/webcal-protocol-client.tsx | 73 ++++ components/settings/composing-settings.tsx | 37 +- .../settings/protocol-handler-settings.tsx | 108 ++++++ lib/__tests__/email-composer-utils.test.ts | 26 ++ lib/__tests__/protocol-handlers.test.ts | 170 +++++++++ lib/email-composer-utils.ts | 23 ++ lib/protocol-handlers/mailto.ts | 115 ++++++ lib/protocol-handlers/session.ts | 357 ++++++++++++++++++ lib/protocol-handlers/webcal.ts | 48 +++ locales/cs/common.json | 44 +++ locales/de/common.json | 44 +++ locales/en/common.json | 44 +++ locales/es/common.json | 44 +++ locales/fr/common.json | 44 +++ locales/it/common.json | 44 +++ locales/ja/common.json | 44 +++ locales/ko/common.json | 44 +++ locales/lv/common.json | 44 +++ locales/nl/common.json | 44 +++ locales/pl/common.json | 44 +++ locales/pt/common.json | 44 +++ locales/ru/common.json | 44 +++ locales/tr/common.json | 44 +++ locales/uk/common.json | 44 +++ locales/zh/common.json | 44 +++ proxy.ts | 6 +- public/sw.js | 157 ++++++++ stores/settings-store.ts | 18 +- 40 files changed, 2514 insertions(+), 55 deletions(-) create mode 100644 app/protocol/mailto/page.tsx create mode 100644 app/protocol/webcal/page.tsx create mode 100644 components/protocol/mailto-protocol-client.tsx create mode 100644 components/protocol/protocol-account-picker.tsx create mode 100644 components/protocol/protocol-launch-handler-provider.tsx create mode 100644 components/protocol/webcal-protocol-client.tsx create mode 100644 components/settings/protocol-handler-settings.tsx create mode 100644 lib/__tests__/email-composer-utils.test.ts create mode 100644 lib/__tests__/protocol-handlers.test.ts create mode 100644 lib/email-composer-utils.ts create mode 100644 lib/protocol-handlers/mailto.ts create mode 100644 lib/protocol-handlers/session.ts create mode 100644 lib/protocol-handlers/webcal.ts diff --git a/app/[locale]/calendar/page.tsx b/app/[locale]/calendar/page.tsx index c64b6424..0d158197 100644 --- a/app/[locale]/calendar/page.tsx +++ b/app/[locale]/calendar/page.tsx @@ -15,6 +15,7 @@ import { useAuthStore, redirectToLogin } from "@/stores/auth-store"; import { useEmailStore } from "@/stores/email-store"; import { useSettingsStore } from "@/stores/settings-store"; import { useIdentityStore } from "@/stores/identity-store"; +import { useAccountStore } from "@/stores/account-store"; import { toast } from "@/stores/toast-store"; import { useIsMobile } from "@/hooks/use-media-query"; import { Button } from "@/components/ui/button"; @@ -37,6 +38,7 @@ import { useRefreshGesture } from "@/hooks/use-refresh-gesture"; import { downloadEventICS } from "@/lib/calendar-ics-export"; import { ICalImportModal } from "@/components/calendar/ical-import-modal"; import { ICalSubscriptionModal } from "@/components/calendar/ical-subscription-modal"; +import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { RecurrenceScopeDialog, type RecurrenceEditScope } from "@/components/calendar/recurrence-scope-dialog"; import { NavigationRail } from "@/components/layout/navigation-rail"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; @@ -56,6 +58,8 @@ import { CreateCalendarModal } from "@/components/calendar/create-calendar-modal import { getUserParticipantId } from "@/lib/calendar-participants"; import { generateBirthdayEvents, createBirthdayCalendar, BIRTHDAY_CALENDAR_ID } from "@/lib/birthday-calendar"; import { debug } from "@/lib/debug"; +import { consumePendingWebcal, hasPendingWebcal, subscribeToPendingWebcal } from "@/lib/protocol-handlers/session"; +import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; type PendingScopeAction = | { type: "edit"; event: CalendarEvent; updates: Partial; sendScheduling?: boolean } @@ -68,9 +72,10 @@ function isRecurringEvent(event: CalendarEvent): boolean { export default function CalendarPage() { const router = useRouter(); const t = useTranslations("calendar"); + const tWebcalAction = useTranslations("calendar.webcal_action"); const isMobile = useIsMobile(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); - const { client, isAuthenticated, logout, checkAuth, isLoading: authLoading } = useAuthStore(); + const { client, isAuthenticated, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading } = useAuthStore(); const [initialCheckDone, setInitialCheckDone] = useState(() => useAuthStore.getState().isAuthenticated && !!useAuthStore.getState().client); const { quota, isPushConnected } = useEmailStore(); const { @@ -96,6 +101,10 @@ export default function CalendarPage() { const [showEventModal, setShowEventModal] = useState(false); const [showImportModal, setShowImportModal] = useState(false); const [showSubscriptionModal, setShowSubscriptionModal] = useState(false); + const [pendingSubscription, setPendingSubscription] = useState<{ url: string; name: string } | null>(null); + const [showWebcalActionChoice, setShowWebcalActionChoice] = useState(false); + const [pendingWebcalAccountChoice, setPendingWebcalAccountChoice] = useState(null); + const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const [editingSubscription, setEditingSubscription] = useState(null); const [sharingCalendarId, setSharingCalendarId] = useState(null); const [defaultCalendarIdForCreate, setDefaultCalendarIdForCreate] = useState(undefined); @@ -156,10 +165,10 @@ export default function CalendarPage() { if (initialCheckDone && !isAuthenticated && !authLoading) { try { sessionStorage.setItem('redirect_after_login', window.location.pathname); } catch { /* ignore */ } redirectToLogin(); - } else if (client && !supportsCalendar) { + } else if (client && !supportsCalendar && !pendingWebcalAccountChoice && !isProtocolAccountSwitching && !pendingSubscription && !showWebcalActionChoice && !hasPendingWebcal()) { router.push("/"); } - }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, router]); + }, [initialCheckDone, isAuthenticated, authLoading, client, supportsCalendar, pendingWebcalAccountChoice, isProtocolAccountSwitching, pendingSubscription, showWebcalActionChoice, router]); useEffect(() => { if (error) { @@ -167,6 +176,84 @@ export default function CalendarPage() { } }, [error]); + const getWebcalProtocolAccounts = useCallback(() => { + const connectedClients = useAuthStore.getState().getAllConnectedClients(); + return useAccountStore.getState().accounts.filter((account) => { + if (!account.isConnected) return false; + return connectedClients.get(account.id)?.supportsCalendars() === true; + }); + }, []); + + const openWebcalForAccount = useCallback(async (pending: ParsedWebcal, accountId: string) => { + setIsProtocolAccountSwitching(true); + try { + if (useAuthStore.getState().activeAccountId !== accountId) { + await switchAccount(accountId); + } + setPendingWebcalAccountChoice(null); + setPendingSubscription({ + url: pending.subscriptionUrl, + name: pending.suggestedName, + }); + setShowWebcalActionChoice(true); + } finally { + setIsProtocolAccountSwitching(false); + } + }, [switchAccount]); + + const handleWebcalProtocolRequest = useCallback((pending: ParsedWebcal) => { + const protocolAccounts = getWebcalProtocolAccounts(); + if (protocolAccounts.length > 1) { + setPendingWebcalAccountChoice(pending); + return; + } + + if (protocolAccounts.length === 0 && !supportsCalendar) { + return; + } + + const accountId = protocolAccounts[0]?.id ?? activeAccountId; + if (accountId) { + void openWebcalForAccount(pending, accountId); + return; + } + + setPendingSubscription({ + url: pending.subscriptionUrl, + name: pending.suggestedName, + }); + setShowWebcalActionChoice(true); + }, [activeAccountId, getWebcalProtocolAccounts, openWebcalForAccount, supportsCalendar]); + + const closeWebcalActionChoice = useCallback(() => { + setShowWebcalActionChoice(false); + setPendingSubscription(null); + }, []); + + const handleImportWebcal = useCallback(() => { + setShowWebcalActionChoice(false); + setShowImportModal(true); + }, []); + + const handleSubscribeWebcal = useCallback(() => { + setShowWebcalActionChoice(false); + setShowSubscriptionModal(true); + }, []); + + useEffect(() => { + if (!isAuthenticated || !client) return; + + const openPendingWebcal = () => { + const pending = consumePendingWebcal(); + if (!pending) return; + + handleWebcalProtocolRequest(pending); + }; + + openPendingWebcal(); + return subscribeToPendingWebcal(openPendingWebcal); + }, [isAuthenticated, client, handleWebcalProtocolRequest]); + useEffect(() => { if (client && !hasFetched.current) { hasFetched.current = true; @@ -955,7 +1042,54 @@ export default function CalendarPage() { }); }, [events, selectedCalendarIds, visibleEvents]); - if (!isAuthenticated || !supportsCalendar) return null; + const renderWebcalAccountPicker = () => pendingWebcalAccountChoice ? ( + void openWebcalForAccount(pendingWebcalAccountChoice, accountId)} + onCancel={() => setPendingWebcalAccountChoice(null)} + /> + ) : null; + + const renderWebcalActionChoice = () => showWebcalActionChoice && pendingSubscription ? ( +
+ + ) : null; + + if (!isAuthenticated) return null; + if (!supportsCalendar) return renderWebcalAccountPicker(); const renderView = () => { if (isLoading && calendars.length === 0) { @@ -1378,14 +1512,23 @@ export default function CalendarPage() { setShowImportModal(false)} + initialUrl={pendingSubscription?.url} + onClose={() => { + setShowImportModal(false); + setPendingSubscription(null); + }} /> )} {showSubscriptionModal && client && ( setShowSubscriptionModal(false)} + initialUrl={pendingSubscription?.url} + initialName={pendingSubscription?.name} + onClose={() => { + setShowSubscriptionModal(false); + setPendingSubscription(null); + }} /> )} @@ -1402,6 +1545,8 @@ export default function CalendarPage() { })()} + {renderWebcalAccountPicker()} + {renderWebcalActionChoice()} - {children} + + {children} + diff --git a/app/[locale]/page.tsx b/app/[locale]/page.tsx index 072c7145..5c73a733 100644 --- a/app/[locale]/page.tsx +++ b/app/[locale]/page.tsx @@ -8,6 +8,7 @@ import { EmailList } from "@/components/email/email-list"; import { EmailViewer } from "@/components/email/email-viewer"; import { EmailComposer } from "@/components/email/email-composer"; import type { ComposerDraftData } from "@/components/email/email-composer"; +import { ProtocolAccountPicker } from "@/components/protocol/protocol-account-picker"; import { ThreadConversationView } from "@/components/email/thread-conversation-view"; import { MobileHeader } from "@/components/layout/mobile-header"; import { ThreadGroup, Email, isUnifiedMailboxId, UNIFIED_ROLE_BY_ID } from "@/lib/jmap/types"; @@ -60,6 +61,9 @@ import { Button } from "@/components/ui/button"; import { useConfig } from "@/hooks/use-config"; import { usePluginStore } from "@/stores/plugin-store"; import { useThemeStore } from "@/stores/theme-store"; +import { consumePendingMailto, subscribeToPendingMailto } from "@/lib/protocol-handlers/session"; +import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; +import { plainTextToComposerBody } from "@/lib/email-composer-utils"; import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks"; import { emailToReadView } from "@/lib/plugin-projection"; @@ -74,6 +78,7 @@ export default function Home() { const [composerDraftText, setComposerDraftText] = useState(""); const [pendingDraft, setPendingDraft] = useState(null); const [composerSessionId, setComposerSessionId] = useState(0); + const suppressComposerStateSaveSessionRef = useRef(null); const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog(); const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog(); const { showAppsModal, inlineApp, loadedApps, handleManageApps, handleInlineApp, closeInlineApp, closeAppsModal } = useSidebarApps(); @@ -89,8 +94,10 @@ export default function Home() { const [isLoadingConversation, setIsLoadingConversation] = useState(false); const [rateLimitSecondsLeft, setRateLimitSecondsLeft] = useState(null); const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null); + const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState(null); + const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false); const markAsReadTimeoutRef = useRef(null); - const { isAuthenticated, client, logout, checkAuth, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); + const { isAuthenticated, client, logout, checkAuth, switchAccount, activeAccountId, isLoading: authLoading, connectionLost, isRateLimited, rateLimitUntil } = useAuthStore(); const { identities } = useIdentityStore(); useIdentitySync(); const trustedSendersAddressBook = useSettingsStore((state) => state.trustedSendersAddressBook); @@ -308,6 +315,13 @@ export default function Home() { [], ); + const getMailtoProtocolAccounts = useCallback(() => { + const connectedClients = useAuthStore.getState().getAllConnectedClients(); + return useAccountStore.getState().accounts.filter((account) => + account.isConnected && connectedClients.has(account.id) + ); + }, []); + // Browser back / forward integration. The restore handler reads the // latest values from a ref so we don't have to recreate the callback on // every render (and so the popstate listener is never stale). @@ -651,6 +665,74 @@ export default function Home() { } }, [initialCheckDone, isAuthenticated, authLoading]); + const openMailtoDraft = useCallback((pending: ParsedMailto) => { + const body = useSettingsStore.getState().plainTextMode + ? pending.body + : plainTextToComposerBody(pending.body); + + if (showComposer) { + suppressComposerStateSaveSessionRef.current = composerSessionId; + } + setComposerSessionId((id) => id + 1); + setPendingDraft({ + to: pending.to.join(", "), + cc: pending.cc.join(", "), + bcc: pending.bcc.join(", "), + subject: pending.subject, + body, + showCc: pending.cc.length > 0, + showBcc: pending.bcc.length > 0, + selectedIdentityId: null, + subAddressTag: "", + mode: "compose", + draftId: null, + }); + setComposerMode("compose"); + setShowComposer(true); + if (isMobile) setActiveView("viewer"); + }, [composerSessionId, isMobile, setActiveView, showComposer]); + + const openMailtoForAccount = useCallback(async (pending: ParsedMailto, accountId: string) => { + setIsProtocolAccountSwitching(true); + try { + if (useAuthStore.getState().activeAccountId !== accountId) { + await switchAccount(accountId); + } + setPendingMailtoAccountChoice(null); + openMailtoDraft(pending); + } finally { + setIsProtocolAccountSwitching(false); + } + }, [openMailtoDraft, switchAccount]); + + const handleMailtoProtocolRequest = useCallback((pending: ParsedMailto) => { + const protocolAccounts = getMailtoProtocolAccounts(); + if (protocolAccounts.length > 1) { + setPendingMailtoAccountChoice(pending); + return; + } + + const accountId = protocolAccounts[0]?.id ?? activeAccountId; + if (accountId) { + void openMailtoForAccount(pending, accountId); + return; + } + + openMailtoDraft(pending); + }, [activeAccountId, getMailtoProtocolAccounts, openMailtoDraft, openMailtoForAccount]); + + useEffect(() => { + if (!isAuthenticated || !client) return; + + const openPendingMailto = () => { + const pending = consumePendingMailto(); + if (pending) handleMailtoProtocolRequest(pending); + }; + + openPendingMailto(); + return subscribeToPendingMailto(openPendingMailto); + }, [isAuthenticated, client, handleMailtoProtocolRequest]); + // Fallback fetch for paths that didn't go through login()'s prefetch // (notably checkAuth on page refresh). The prefetch in auth-store/login() // populates mailboxes before this effect first runs, so on the post-login @@ -2373,7 +2455,13 @@ export default function Home() { } : undefined)} initialDraftText={composerDraftText} initialData={pendingDraft} - onSaveState={(data) => setPendingDraft(data)} + onSaveState={(data) => { + if (suppressComposerStateSaveSessionRef.current === composerSessionId) { + suppressComposerStateSaveSessionRef.current = null; + return; + } + setPendingDraft(data); + }} onSend={async (data) => { await handleEmailSend(data); setPendingDraft(null); @@ -2528,6 +2616,17 @@ export default function Home() {
+ {pendingMailtoAccountChoice && ( + void openMailtoForAccount(pendingMailtoAccountChoice, accountId)} + onCancel={() => setPendingMailtoAccountChoice(null)} + /> + )} diff --git a/app/[locale]/settings/page.tsx b/app/[locale]/settings/page.tsx index 9d929361..da3de6fa 100644 --- a/app/[locale]/settings/page.tsx +++ b/app/[locale]/settings/page.tsx @@ -26,6 +26,7 @@ import { Bell, Puzzle, LayoutGrid, + Link as LinkIcon, BookOpen, PenLine, EyeOff, @@ -63,6 +64,7 @@ import { SidebarAppsSettings } from '@/components/settings/sidebar-apps-settings import { NotificationSettings } from '@/components/settings/notification-settings'; import { ThemesSettings } from '@/components/settings/themes-settings'; import { PluginsSettings } from '@/components/settings/plugins-settings'; +import { ProtocolHandlerSettings } from '@/components/settings/protocol-handler-settings'; import { useAuthStore, redirectToLogin } from '@/stores/auth-store'; import { useEmailStore } from '@/stores/email-store'; import { usePluginStore } from '@/stores/plugin-store'; @@ -98,6 +100,7 @@ type Tab = | 'calendar' | 'contacts' | 'files' + | 'protocol_handlers' | 'sidebar_apps' | 'about_data' | 'themes' @@ -133,6 +136,7 @@ const tabIcons: Record = { calendar: Calendar, contacts: BookUser, files: HardDrive, + protocol_handlers: LinkIcon, sidebar_apps: PanelLeftClose, about_data: Info, themes: Palette, @@ -211,6 +215,7 @@ const tabSearchPaths: Record = { calendar: ['calendar.settings', 'calendar.management'], contacts: ['settings.contacts', 'contacts'], files: ['settings.files'], + protocol_handlers: ['protocol_handlers'], sidebar_apps: ['settings.sidebar_apps', 'sidebar_apps'], about_data: ['settings.advanced'], themes: [], @@ -240,6 +245,7 @@ const tabKeywords: Record = { calendar: 'event schedule appointment meeting timezone', contacts: 'address book contact', files: 'attachments cloud drive storage upload', + protocol_handlers: 'mailto webcal links default app protocol handler', sidebar_apps: 'apps webview iframe', about_data: 'export import storage quota privacy backup', themes: 'custom theme css skin appearance', @@ -560,6 +566,7 @@ export default function SettingsPage() { { id: 'account', label: t('tabs.account'), icon: tabIcons.account, group: 'general' }, { id: 'language', label: t('tabs.language'), icon: tabIcons.language, group: 'general' }, { id: 'notifications', label: t('tabs.notifications'), icon: tabIcons.notifications, group: 'general' }, + { id: 'protocol_handlers', label: t('tabs.protocol_handlers'), icon: tabIcons.protocol_handlers, group: 'general' }, // Appearance { id: 'appearance', label: t('tabs.appearance'), icon: tabIcons.appearance, group: 'appearance' }, @@ -666,6 +673,7 @@ export default function SettingsPage() { {effectiveActiveTab === 'calendar' && <>
} {effectiveActiveTab === 'contacts' && <>
} {effectiveActiveTab === 'files' && } + {effectiveActiveTab === 'protocol_handlers' && } {effectiveActiveTab === 'sidebar_apps' && } {effectiveActiveTab === 'about_data' && } {effectiveActiveTab === 'themes' && } diff --git a/app/manifest.ts b/app/manifest.ts index 6e5da59e..6d2d065c 100644 --- a/app/manifest.ts +++ b/app/manifest.ts @@ -2,13 +2,26 @@ import type { MetadataRoute } from "next"; export const dynamic = "force-dynamic"; +type WebAppProtocolHandler = { + protocol: string; + url: string; +}; + +type ExtendedManifest = MetadataRoute.Manifest & { + protocol_handlers?: WebAppProtocolHandler[]; + launch_handler?: { + client_mode?: "navigate-existing" | "auto" | "focus-existing" | "navigate-new" + | Array<"navigate-existing" | "auto" | "focus-existing" | "navigate-new">; + }; +}; + // Manifest paths must include the deployment subpath - browsers resolve them // against the document origin, not the manifest's location, and Next.js does // not auto-prefix string literals inside MetadataRoute payloads. const BASE_PATH = (process.env.NEXT_PUBLIC_BASE_PATH ?? "").replace(/\/+$/, ""); const withBase = (p: string) => `${BASE_PATH}${p}`; -export default function manifest(): MetadataRoute.Manifest { +export default function manifest(): ExtendedManifest { const appName = process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || @@ -57,5 +70,12 @@ export default function manifest(): MetadataRoute.Manifest { { src: withBase("/screenshot-540x720.png"), sizes: "540x720", type: "image/png" }, { src: withBase("/screenshot-1280x720.png"), sizes: "1280x720", type: "image/png" }, ], + protocol_handlers: [ + { protocol: "mailto", url: withBase("/protocol/mailto?url=%s") }, + { protocol: "webcal", url: withBase("/protocol/webcal?url=%s") }, + ], + launch_handler: { + client_mode: ["focus-existing", "navigate-new"], + }, }; } diff --git a/app/protocol/mailto/page.tsx b/app/protocol/mailto/page.tsx new file mode 100644 index 00000000..f2797d69 --- /dev/null +++ b/app/protocol/mailto/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from "next-intl/server"; +import { MailtoProtocolClient } from "@/components/protocol/mailto-protocol-client"; + +export default async function MailtoProtocolPage() { + const t = await getTranslations("protocol_handlers"); + + return ; +} diff --git a/app/protocol/webcal/page.tsx b/app/protocol/webcal/page.tsx new file mode 100644 index 00000000..47cdbfa8 --- /dev/null +++ b/app/protocol/webcal/page.tsx @@ -0,0 +1,8 @@ +import { getTranslations } from "next-intl/server"; +import { WebcalProtocolClient } from "@/components/protocol/webcal-protocol-client"; + +export default async function WebcalProtocolPage() { + const t = await getTranslations("protocol_handlers"); + + return ; +} diff --git a/components/calendar/ical-import-modal.tsx b/components/calendar/ical-import-modal.tsx index 3324fb7a..0c987b96 100644 --- a/components/calendar/ical-import-modal.tsx +++ b/components/calendar/ical-import-modal.tsx @@ -17,6 +17,7 @@ interface ICalImportModalProps { calendars: Calendar[]; client: IJMAPClient; onClose: () => void; + initialUrl?: string; } const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB @@ -25,7 +26,7 @@ const ACCEPTED_EXTENSIONS = [".ics", ".ical"]; type ImportStep = "select" | "preview" | "importing"; type ImportMode = "file" | "url"; -export function ICalImportModal({ calendars, client, onClose }: ICalImportModalProps) { +export function ICalImportModal({ calendars, client, onClose, initialUrl }: ICalImportModalProps) { const t = useTranslations("calendar.import"); const tCal = useTranslations("calendar"); const tCommon = useTranslations("common"); @@ -43,8 +44,8 @@ export function ICalImportModal({ calendars, client, onClose }: ICalImportModalP const [isParsing, setIsParsing] = useState(false); const [isDragging, setIsDragging] = useState(false); const [error, setError] = useState(null); - const [importMode, setImportMode] = useState("file"); - const [urlInput, setUrlInput] = useState(""); + const [importMode, setImportMode] = useState(initialUrl ? "url" : "file"); + const [urlInput, setUrlInput] = useState(initialUrl || ""); const [isFetchingUrl, setIsFetchingUrl] = useState(false); const fileInputRef = useRef(null); const modalRef = useRef(null); diff --git a/components/calendar/ical-subscription-modal.tsx b/components/calendar/ical-subscription-modal.tsx index ddf72791..c0fe2411 100644 --- a/components/calendar/ical-subscription-modal.tsx +++ b/components/calendar/ical-subscription-modal.tsx @@ -13,9 +13,11 @@ interface ICalSubscriptionModalProps { client: IJMAPClient; onClose: () => void; editSubscription?: ICalSubscription; + initialUrl?: string; + initialName?: string; } -export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICalSubscriptionModalProps) { +export function ICalSubscriptionModal({ client, onClose, editSubscription, initialUrl, initialName }: ICalSubscriptionModalProps) { const t = useTranslations("calendar.subscription"); const tCommon = useTranslations("common"); const addICalSubscription = useCalendarStore((s) => s.addICalSubscription); @@ -23,8 +25,8 @@ export function ICalSubscriptionModal({ client, onClose, editSubscription }: ICa const isEdit = !!editSubscription; - const [url, setUrl] = useState(editSubscription?.url || ""); - const [name, setName] = useState(editSubscription?.name || ""); + const [url, setUrl] = useState(editSubscription?.url || initialUrl || ""); + const [name, setName] = useState(editSubscription?.name || initialName || ""); const [color, setColor] = useState(editSubscription?.color || "#3b82f6"); const [refreshInterval, setRefreshInterval] = useState(editSubscription?.refreshInterval || 60); const [isSubmitting, setIsSubmitting] = useState(false); diff --git a/components/protocol/mailto-protocol-client.tsx b/components/protocol/mailto-protocol-client.tsx new file mode 100644 index 00000000..e6b8e2b1 --- /dev/null +++ b/components/protocol/mailto-protocol-client.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useEffect } from "react"; +import { parseMailto } from "@/lib/protocol-handlers/mailto"; +import { requestOpenMailtoInExistingClient, savePendingMailto } from "@/lib/protocol-handlers/session"; +import { useSettingsStore } from "@/stores/settings-store"; + +type StandaloneNavigator = Navigator & { standalone?: boolean }; + +function getProtocolPathPrefix(): string { + const marker = "/protocol/mailto"; + const index = window.location.pathname.indexOf(marker); + return index > 0 ? window.location.pathname.slice(0, index) : ""; +} + +function returnToSourcePage() { + window.close(); + + window.setTimeout(() => { + if (window.history.length > 1) { + window.history.back(); + } + }, 150); +} + +function openFallbackAppTab(raw: string): boolean { + const url = `${getProtocolPathPrefix()}/protocol/mailto?url=${encodeURIComponent(raw)}&fallback=1`; + const opened = window.open(url, "_blank"); + if (!opened) return false; + opened.opener = null; + return true; +} + +function shouldOpenFallbackAppTab(): boolean { + const standalone = window.matchMedia?.("(display-mode: standalone)").matches + || (navigator as StandaloneNavigator).standalone === true; + return !standalone && window.history.length > 1; +} + +async function focusExistingClient() { + if (!("serviceWorker" in navigator)) return; + + try { + const registration = await navigator.serviceWorker.ready; + const worker = navigator.serviceWorker.controller ?? registration.active; + worker?.postMessage({ type: "focus-existing-mailto-client" }); + } catch { + // Focusing is a progressive enhancement; the composer handoff still works. + } +} + +interface MailtoProtocolClientProps { + openingText: string; +} + +export function MailtoProtocolClient({ openingText }: MailtoProtocolClientProps) { + useEffect(() => { + let cancelled = false; + + async function handleMailto() { + const params = new URLSearchParams(window.location.search); + const raw = params.get("url"); + const isFallbackAppTab = params.get("fallback") === "1"; + const openMode = useSettingsStore.getState().protocolOpenMode; + const parsed = raw ? parseMailto(raw) : null; + + if (parsed) { + if (!isFallbackAppTab && openMode === "new-tab") { + if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) { + returnToSourcePage(); + return; + } + } else if (!isFallbackAppTab) { + const delivered = await requestOpenMailtoInExistingClient(parsed); + if (cancelled) return; + + if (delivered) { + void focusExistingClient(); + returnToSourcePage(); + return; + } + + if (raw && shouldOpenFallbackAppTab() && openFallbackAppTab(raw)) { + returnToSourcePage(); + return; + } + } + + savePendingMailto(parsed); + } + + window.location.replace(`${getProtocolPathPrefix()}/`); + } + + void handleMailto(); + + return () => { + cancelled = true; + }; + }, []); + + return ( +
+

{openingText}

+
+ ); +} diff --git a/components/protocol/protocol-account-picker.tsx b/components/protocol/protocol-account-picker.tsx new file mode 100644 index 00000000..76fdcf8f --- /dev/null +++ b/components/protocol/protocol-account-picker.tsx @@ -0,0 +1,161 @@ +"use client"; + +import { Loader2, X } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { getInitials } from "@/lib/account-utils"; +import type { ParsedMailto } from "@/lib/protocol-handlers/mailto"; +import type { ParsedWebcal } from "@/lib/protocol-handlers/webcal"; +import type { AccountEntry } from "@/stores/account-store"; +import { cn } from "@/lib/utils"; + +type ProtocolAccountPickerProps = { + accounts: AccountEntry[]; + activeAccountId: string | null; + isSwitching?: boolean; + onSelect: (accountId: string) => void; + onCancel: () => void; +} & ( + | { kind: "mailto"; operation?: ParsedMailto } + | { kind: "webcal"; operation?: ParsedWebcal } +); + +function getHost(value: string): string { + try { + return new URL(value).hostname; + } catch { + return value; + } +} + +export function ProtocolAccountPicker({ + kind, + accounts, + activeAccountId, + isSwitching = false, + onSelect, + onCancel, + operation, +}: ProtocolAccountPickerProps) { + const t = useTranslations("protocol_handlers"); + const tCommon = useTranslations("common"); + const details = operation + ? kind === "mailto" + ? [ + { label: t("detail_to"), value: operation.to.join(", ") || "-" }, + { label: t("detail_subject"), value: operation.subject || t("detail_no_subject") }, + ] + : [ + { label: t("detail_calendar"), value: operation.suggestedName }, + { label: t("detail_source"), value: getHost(operation.subscriptionUrl) }, + ] + : []; + + return ( +
+ + ); +} diff --git a/components/protocol/protocol-launch-handler-provider.tsx b/components/protocol/protocol-launch-handler-provider.tsx new file mode 100644 index 00000000..7e8651e2 --- /dev/null +++ b/components/protocol/protocol-launch-handler-provider.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { useEffect } from "react"; +import type { ReactNode } from "react"; +import { useTranslations } from "next-intl"; +import { usePathname, useRouter } from "@/i18n/navigation"; +import { getPathPrefix } from "@/lib/browser-navigation"; +import { parseMailto } from "@/lib/protocol-handlers/mailto"; +import { parseWebcal } from "@/lib/protocol-handlers/webcal"; +import { + listenForMailtoRequests, + notifyPendingMailto, + notifyPendingWebcal, + requestOpenMailtoInExistingClient, + savePendingMailto, + savePendingWebcal, +} from "@/lib/protocol-handlers/session"; +import { useSettingsStore } from "@/stores/settings-store"; + +type LaunchParams = { targetURL?: string }; +type StandaloneNavigator = Navigator & { standalone?: boolean }; + +declare global { + interface Window { + launchQueue?: { + setConsumer: (consumer: (launchParams: LaunchParams) => void) => void; + }; + } +} + +function getProtocolLaunch(targetURL: string): + | { kind: "mailto"; raw: string } + | { kind: "webcal"; raw: string } + | null { + let url: URL; + try { + url = new URL(targetURL, window.location.origin); + } catch { + return null; + } + + if (url.origin !== window.location.origin) return null; + + const raw = url.searchParams.get("url"); + if (!raw) return null; + + if (url.pathname.includes("/protocol/mailto")) return { kind: "mailto", raw }; + if (url.pathname.includes("/protocol/webcal")) return { kind: "webcal", raw }; + return null; +} + +function isStandaloneDisplayMode() { + return window.matchMedia?.("(display-mode: standalone)").matches + || (navigator as StandaloneNavigator).standalone === true; +} + +function openProtocolInNewTab(protocol: "mailto" | "webcal", raw: string): boolean { + const url = `${getPathPrefix()}/protocol/${protocol}?url=${encodeURIComponent(raw)}&fallback=1`; + const opened = window.open(url, "_blank"); + if (!opened) return false; + opened.opener = null; + return true; +} + +interface ProtocolLaunchHandlerProviderProps { + children: ReactNode; +} + +export function ProtocolLaunchHandlerProvider({ children }: ProtocolLaunchHandlerProviderProps) { + const t = useTranslations("protocol_handlers"); + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + if (pathname.startsWith("/protocol/")) return; + + return listenForMailtoRequests((pending) => { + savePendingMailto(pending); + notifyPendingMailto(); + if (pathname !== "/") router.push("/"); + }, () => ({ + path: pathname, + standalone: isStandaloneDisplayMode(), + focusNotificationTitle: t("focus_notification_title"), + focusNotificationBody: t("focus_notification_body"), + })); + }, [pathname, router, t]); + + useEffect(() => { + if (typeof window === "undefined" || !window.launchQueue) return; + + window.launchQueue.setConsumer((launchParams) => { + if (!launchParams.targetURL) return; + + const launch = getProtocolLaunch(launchParams.targetURL); + if (!launch) return; + + if (launch.kind === "mailto") { + const parsed = parseMailto(launch.raw); + if (!parsed) return; + + if (useSettingsStore.getState().protocolOpenMode === "new-tab") { + if (openProtocolInNewTab("mailto", launch.raw)) return; + savePendingMailto(parsed); + notifyPendingMailto(); + if (pathname !== "/") router.push("/"); + return; + } + + void requestOpenMailtoInExistingClient(parsed).then((delivered) => { + if (delivered) return; + savePendingMailto(parsed); + notifyPendingMailto(); + if (pathname !== "/") router.push("/"); + }); + return; + } + + const parsed = parseWebcal(launch.raw); + if (!parsed) return; + + if (useSettingsStore.getState().protocolOpenMode === "new-tab") { + if (openProtocolInNewTab("webcal", launch.raw)) return; + } + + savePendingWebcal(parsed); + notifyPendingWebcal(); + if (pathname !== "/calendar") router.push("/calendar"); + }); + }, [pathname, router]); + + return children; +} diff --git a/components/protocol/webcal-protocol-client.tsx b/components/protocol/webcal-protocol-client.tsx new file mode 100644 index 00000000..7bd697ff --- /dev/null +++ b/components/protocol/webcal-protocol-client.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect } from "react"; +import { parseWebcal } from "@/lib/protocol-handlers/webcal"; +import { savePendingWebcal } from "@/lib/protocol-handlers/session"; +import { useSettingsStore } from "@/stores/settings-store"; + +type StandaloneNavigator = Navigator & { standalone?: boolean }; + +function getProtocolPathPrefix(): string { + const marker = "/protocol/webcal"; + const index = window.location.pathname.indexOf(marker); + return index > 0 ? window.location.pathname.slice(0, index) : ""; +} + +function returnToSourcePage() { + window.close(); + + window.setTimeout(() => { + if (window.history.length > 1) { + window.history.back(); + } + }, 150); +} + +function openFallbackAppTab(raw: string): boolean { + const url = `${getProtocolPathPrefix()}/protocol/webcal?url=${encodeURIComponent(raw)}&fallback=1`; + const opened = window.open(url, "_blank"); + if (!opened) return false; + opened.opener = null; + return true; +} + +function shouldOpenFallbackAppTab(): boolean { + const standalone = window.matchMedia?.("(display-mode: standalone)").matches + || (navigator as StandaloneNavigator).standalone === true; + return !standalone && window.history.length > 1; +} + +interface WebcalProtocolClientProps { + openingText: string; +} + +export function WebcalProtocolClient({ openingText }: WebcalProtocolClientProps) { + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const raw = params.get("url"); + const isFallbackAppTab = params.get("fallback") === "1"; + + if (raw) { + const parsed = parseWebcal(raw); + if (parsed) { + if (!isFallbackAppTab + && useSettingsStore.getState().protocolOpenMode === "new-tab" + && shouldOpenFallbackAppTab() + && openFallbackAppTab(raw)) { + returnToSourcePage(); + return; + } + + savePendingWebcal(parsed); + } + } + + window.location.replace(`${getProtocolPathPrefix()}/calendar`); + }, []); + + return ( +
+

{openingText}

+
+ ); +} diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index 9223d78b..93e17c98 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -1,12 +1,10 @@ "use client"; -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { useTranslations } from 'next-intl'; -import { useConfig } from '@/hooks/use-config'; import { useSettingsStore } from '@/stores/settings-store'; import { SettingsSection, SettingItem, Select, ToggleSwitch } from './settings-section'; -import { Mail, X } from 'lucide-react'; -import { getPathPrefix } from '@/lib/browser-navigation'; +import { X } from 'lucide-react'; import { SUPPORTED_SUB_ADDRESS_DELIMITERS, isSupportedSubAddressDelimiter, @@ -18,8 +16,6 @@ const DEFAULT_CUSTOM_DELIMITER = '~'; export function ComposingSettings() { const t = useTranslations('settings.email_behavior'); - const { appName } = useConfig(); - const [defaultMailStatus, setDefaultMailStatus] = useState<'idle' | 'success' | 'error'>('idle'); const [newKeyword, setNewKeyword] = useState(''); const { @@ -32,17 +28,6 @@ export function ComposingSettings() { updateSetting, } = useSettingsStore(); - const handleSetDefaultMailProgram = useCallback(() => { - try { - if (typeof navigator !== 'undefined' && navigator.registerProtocolHandler) { - navigator.registerProtocolHandler('mailto', `${window.location.origin}${getPathPrefix()}/compose?mailto=%s`); - setDefaultMailStatus('success'); - } - } catch { - setDefaultMailStatus('error'); - } - }, []); - return ( @@ -168,24 +153,6 @@ export function ComposingSettings() {
)} - - -
- - {defaultMailStatus === 'success' && ( -

{t('default_mail_program.success')}

- )} - {defaultMailStatus === 'error' && ( -

{t('default_mail_program.error')}

- )} -
-
); } diff --git a/components/settings/protocol-handler-settings.tsx b/components/settings/protocol-handler-settings.tsx new file mode 100644 index 00000000..c82f2849 --- /dev/null +++ b/components/settings/protocol-handler-settings.tsx @@ -0,0 +1,108 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button } from "@/components/ui/button"; +import { getPathPrefix } from "@/lib/browser-navigation"; +import { useSettingsStore } from "@/stores/settings-store"; +import type { ProtocolOpenMode } from "@/stores/settings-store"; +import { toast } from "@/stores/toast-store"; +import { SettingsSection, SettingItem, Select } from "./settings-section"; + +type Protocol = "mailto" | "webcal"; + +function canRegisterProtocolHandler(): boolean { + return typeof navigator !== "undefined" + && "registerProtocolHandler" in navigator + && typeof window !== "undefined" + && window.isSecureContext; +} + +function getProtocolHandlerUrl(protocol: Protocol) { + return `${window.location.origin}${getPathPrefix()}/protocol/${protocol}?url=%s`; +} + +function registerProtocolHandler(protocol: Protocol) { + navigator.registerProtocolHandler( + protocol, + getProtocolHandlerUrl(protocol), + ); +} + +interface ProtocolHandlerSettingsProps { + supportsCalendar: boolean; +} + +export function ProtocolHandlerSettings({ supportsCalendar }: ProtocolHandlerSettingsProps) { + const t = useTranslations("protocol_handlers"); + const protocolOpenMode = useSettingsStore((state) => state.protocolOpenMode); + const updateSetting = useSettingsStore((state) => state.updateSetting); + const [supported, setSupported] = useState(false); + + useEffect(() => { + setSupported(canRegisterProtocolHandler()); + }, []); + + const handleOpenModeChange = async (value: string) => { + const openMode = value as ProtocolOpenMode; + + if (openMode === "active-session" + && typeof window !== "undefined" + && "Notification" in window + && Notification.permission === "default") { + await Notification.requestPermission(); + } + + updateSetting("protocolOpenMode", openMode); + }; + + const handleRegister = (protocol: Protocol) => { + try { + registerProtocolHandler(protocol); + toast.success(protocol === "mailto" ? t("mailto_registered") : t("webcal_registered")); + } catch { + toast.error(t("registration_failed")); + } + }; + + const renderRegistrationControl = (protocol: Protocol) => { + return ( + + ); + }; + + return ( + + {!supported && ( +
+ {t("unsupported")} +
+ )} + + + {renderRegistrationControl("mailto")} + + + {supportsCalendar && ( + + {renderRegistrationControl("webcal")} + + )} + + +