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
This commit is contained in:
Lucas Gaitzsch
2026-05-12 20:49:05 +02:00
committed by Linus Rath
parent 8b0e2052cf
commit 3f444a8912
40 changed files with 2514 additions and 55 deletions
+4 -3
View File
@@ -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<string | null>(null);
const [importMode, setImportMode] = useState<ImportMode>("file");
const [urlInput, setUrlInput] = useState("");
const [importMode, setImportMode] = useState<ImportMode>(initialUrl ? "url" : "file");
const [urlInput, setUrlInput] = useState(initialUrl || "");
const [isFetchingUrl, setIsFetchingUrl] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
@@ -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);
@@ -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 (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-[1px]" onClick={onCancel} aria-hidden="true" />
<div
role="dialog"
aria-modal="true"
aria-label={t("select_account_title")}
className="relative w-full max-w-md rounded-lg border border-border bg-background shadow-xl animate-in zoom-in-95 duration-200"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<h2 className="text-lg font-semibold text-foreground">{t("select_account_title")}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{kind === "mailto" ? t("select_mailto_account") : t("select_webcal_account")}
</p>
</div>
<button
type="button"
onClick={onCancel}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
aria-label={tCommon("close")}
>
<X className="h-5 w-5" />
</button>
</div>
{details.length > 0 && (
<div className="border-b border-border bg-muted/40 px-5 py-3">
<dl className="space-y-1.5 text-sm">
{details.map((detail) => (
<div key={detail.label} className="grid grid-cols-[5.5rem_minmax(0,1fr)] gap-3">
<dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{detail.label}</dt>
<dd className="truncate text-foreground" title={detail.value}>{detail.value}</dd>
</div>
))}
</dl>
</div>
)}
<div className="max-h-80 overflow-y-auto p-2">
{accounts.map((account) => {
const isActive = account.id === activeAccountId;
const initials = getInitials(account.displayName || account.label, account.email || account.username);
let host = account.serverUrl;
try {
host = new URL(account.serverUrl).hostname;
} catch {
// Keep the configured value when it is not an absolute URL.
}
return (
<button
key={account.id}
type="button"
disabled={isSwitching}
onClick={() => onSelect(account.id)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-left transition-colors",
isActive ? "bg-accent/50" : "hover:bg-muted",
isSwitching && "cursor-wait opacity-70"
)}
>
<div
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium text-white"
style={{ backgroundColor: account.avatarColor }}
>
{initials}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{account.displayName || account.label}
</span>
{isActive && (
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary">
{t("active_account")}
</span>
)}
</div>
<p className="truncate text-xs text-muted-foreground">{account.email || account.username}</p>
<p className="truncate text-[10px] text-muted-foreground">{host}</p>
</div>
</button>
);
})}
</div>
<div className="flex items-center justify-between border-t border-border px-5 py-3">
{isSwitching ? (
<span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t("switching_account")}
</span>
) : (
<span className="text-xs text-muted-foreground">{t("select_account_note")}</span>
)}
<button
type="button"
onClick={onCancel}
disabled={isSwitching}
className="rounded-md px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
>
{tCommon("cancel")}
</button>
</div>
</div>
</div>
);
}
@@ -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;
}
@@ -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 (
<main className="flex min-h-screen items-center justify-center">
<p>{openingText}</p>
</main>
);
}
+2 -35
View File
@@ -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 (
<SettingsSection title={t('title')} description={t('description')}>
<SettingItem label={t('auto_select_reply_identity.label')} description={t('auto_select_reply_identity.description')}>
@@ -168,24 +153,6 @@ export function ComposingSettings() {
</form>
</div>
)}
<SettingItem label={t('default_mail_program.label')} description={t('default_mail_program.description', { appName: appName || 'Bulwark' })}>
<div className="flex flex-col items-end gap-1">
<button
onClick={handleSetDefaultMailProgram}
className="flex items-center gap-2 px-3 py-1.5 bg-muted hover:bg-accent rounded-md transition-colors"
>
<Mail className="w-4 h-4" />
<span className="text-sm text-foreground">{t('default_mail_program.button')}</span>
</button>
{defaultMailStatus === 'success' && (
<p className="text-xs text-green-600 dark:text-green-400">{t('default_mail_program.success')}</p>
)}
{defaultMailStatus === 'error' && (
<p className="text-xs text-destructive">{t('default_mail_program.error')}</p>
)}
</div>
</SettingItem>
</SettingsSection>
);
}
@@ -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 (
<Button size="sm" onClick={() => handleRegister(protocol)} disabled={!supported}>
{protocol === "mailto" ? t("register_mailto") : t("register_webcal")}
</Button>
);
};
return (
<SettingsSection title={t("title")} description={t("description")}>
{!supported && (
<div className="rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
{t("unsupported")}
</div>
)}
<SettingItem label={t("mailto_label")} description={t("mailto_description")}>
{renderRegistrationControl("mailto")}
</SettingItem>
{supportsCalendar && (
<SettingItem label={t("webcal_label")} description={t("webcal_description")}>
{renderRegistrationControl("webcal")}
</SettingItem>
)}
<SettingItem label={t("protocol_open_mode_label")} description={t("protocol_open_mode_description")}>
<Select
value={protocolOpenMode}
onChange={handleOpenModeChange}
options={[
{ value: "new-tab", label: t("protocol_open_mode_new_tab") },
{ value: "active-session", label: t("protocol_open_mode_active_session") },
]}
/>
</SettingItem>
<p className="text-xs text-muted-foreground">{t("browser_note")}</p>
</SettingsSection>
);
}