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:
committed by
Linus Rath
parent
8b0e2052cf
commit
3f444a8912
+101
-2
@@ -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<ComposerDraftData | null>(null);
|
||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||
const suppressComposerStateSaveSessionRef = useRef<number | null>(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<number | null>(null);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<{ blobId: string; name: string; type?: string } | null>(null);
|
||||
const [pendingMailtoAccountChoice, setPendingMailtoAccountChoice] = useState<ParsedMailto | null>(null);
|
||||
const [isProtocolAccountSwitching, setIsProtocolAccountSwitching] = useState(false);
|
||||
const markAsReadTimeoutRef = useRef<NodeJS.Timeout | null>(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() {
|
||||
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />
|
||||
|
||||
<SidebarAppsModal isOpen={showAppsModal} onClose={closeAppsModal} />
|
||||
{pendingMailtoAccountChoice && (
|
||||
<ProtocolAccountPicker
|
||||
kind="mailto"
|
||||
operation={pendingMailtoAccountChoice}
|
||||
accounts={getMailtoProtocolAccounts()}
|
||||
activeAccountId={activeAccountId}
|
||||
isSwitching={isProtocolAccountSwitching}
|
||||
onSelect={(accountId) => void openMailtoForAccount(pendingMailtoAccountChoice, accountId)}
|
||||
onCancel={() => setPendingMailtoAccountChoice(null)}
|
||||
/>
|
||||
)}
|
||||
<ConfirmDialog {...confirmDialogProps} />
|
||||
<PromptDialog {...promptDialogProps} />
|
||||
<TotpReauthDialog />
|
||||
|
||||
Reference in New Issue
Block a user