mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 00:12:39 +00:00
feat: address bar reflects the focused Pro tab + live deep-link delivery to mounted surfaces
This commit is contained in:
@@ -13,7 +13,9 @@ import { useSettingsStore } from "@/stores/settings-store";
|
||||
import { useDeviceDetection } from "@/hooks/use-media-query";
|
||||
import { EmbeddedContext } from "@/hooks/use-is-embedded";
|
||||
import { PaneSizeContext } from "@/hooks/use-pane-size";
|
||||
import { PaneIdContext } from "@/hooks/use-pane-context";
|
||||
import { PaneIdContext, ProTabFocusContext } from "@/hooks/use-pane-context";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { appPath, buildMailPath } from "@/lib/deep-links";
|
||||
import { ProTabBar } from "@/components/pro/pro-tab-bar";
|
||||
import {
|
||||
PRO_TAB_DRAG_MIME,
|
||||
@@ -163,7 +165,11 @@ function Pane({ paneId, tabs, activeTabId, loadedTabIds, onPaneFocus, isFocused
|
||||
className={cn("absolute inset-0 overflow-hidden", !isActive && "hidden")}
|
||||
aria-hidden={!isActive}
|
||||
>
|
||||
{renderTabBody(tab)}
|
||||
{/* Exactly one tab across both panes is "focused" - it owns
|
||||
shared single-instance concerns like the address bar. */}
|
||||
<ProTabFocusContext.Provider value={isActive && isFocused}>
|
||||
{renderTabBody(tab)}
|
||||
</ProTabFocusContext.Provider>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -262,6 +268,45 @@ export default function ProHome() {
|
||||
return tabs.find((t) => t.id === id) ?? null;
|
||||
}, [tabs, focusedPaneId, activeMainTabId, activeSplitTabId]);
|
||||
|
||||
// ---- Address bar: reflect the focused tab's permalink ----
|
||||
// Mail-family tabs are reflected here (MailApp's own URL machinery is
|
||||
// suppressed when embedded); calendar/contacts/files/settings write their
|
||||
// own, more precise paths via useDeepLinkUrl gated on ProTabFocusContext.
|
||||
// replaceState only, and ProInterfaceRedirect doesn't observe it - a reload
|
||||
// of the shown URL goes through the redirect's handoff and restores the view.
|
||||
const selectedMailbox = useEmailStore((s) => s.selectedMailbox);
|
||||
const selectedEmailId = useEmailStore((s) => s.selectedEmail?.id ?? null);
|
||||
const mailboxes = useEmailStore((s) => s.mailboxes);
|
||||
const accountMailboxes = useEmailStore((s) => s.accountMailboxes);
|
||||
const reflectedPath = useMemo(() => {
|
||||
if (!isAuthenticated || !client || !focusedActiveTab) return null;
|
||||
switch (focusedActiveTab.kind) {
|
||||
case 'mail':
|
||||
return appPath(buildMailPath(
|
||||
{ mailboxId: selectedMailbox || null, emailId: selectedEmailId, threadId: null },
|
||||
mailboxes,
|
||||
));
|
||||
case 'email':
|
||||
return focusedActiveTab.emailData
|
||||
? appPath(buildMailPath({ mailboxId: null, emailId: focusedActiveTab.emailData.emailId, threadId: null }))
|
||||
: null;
|
||||
case 'folder': {
|
||||
const data = focusedActiveTab.folderData;
|
||||
if (!data) return null;
|
||||
const list = data.accountId ? accountMailboxes[data.accountId] ?? [] : mailboxes;
|
||||
const path = appPath(buildMailPath({ mailboxId: data.mailboxId, emailId: null, threadId: null }, list));
|
||||
return data.accountId ? `${path}?account=${encodeURIComponent(data.accountId)}` : path;
|
||||
}
|
||||
case 'compose':
|
||||
// A draft has no permalink - fall back to the shell's own route so
|
||||
// the bar never shows a message the user is no longer looking at.
|
||||
return appPath('/pro');
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}, [isAuthenticated, client, focusedActiveTab, selectedMailbox, selectedEmailId, mailboxes, accountMailboxes]);
|
||||
useDeepLinkUrl(reflectedPath);
|
||||
|
||||
const handleRailNavigate = (itemId: 'mail' | 'calendar' | 'contacts' | 'files' | 'settings') => {
|
||||
openTab(itemId);
|
||||
return true;
|
||||
|
||||
@@ -48,6 +48,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
|
||||
import { useProMultiAccountCalendars } from "@/hooks/use-pro-multi-account-calendars";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
import { sanitizeOutgoingCalendarEventData } from "@/lib/calendar-event-normalization";
|
||||
@@ -67,8 +68,8 @@ 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 { appPath, buildCalendarPath, parseCalendarPath, type CalendarDeepLink } from "@/lib/deep-links";
|
||||
import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
|
||||
@@ -591,22 +592,16 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
|
||||
// `/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;
|
||||
|
||||
// Held in a ref so the live-delivery subscription (Pro shell) always calls
|
||||
// the copy that closes over the current client and handlers.
|
||||
const applyCalendarDeepLink = (link: CalendarDeepLink) => {
|
||||
if (link.kind === 'view') {
|
||||
setViewMode(link.view);
|
||||
if (link.date) setSelectedDate(link.date);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client) return;
|
||||
void (async () => {
|
||||
try {
|
||||
const event = await client.getCalendarEvent(link.id, link.accountId);
|
||||
@@ -622,21 +617,50 @@ export function CalendarApp({ linkSegments }: CalendarAppProps = {}) {
|
||||
toast.error(tDeepLink('event_not_found'));
|
||||
}
|
||||
})();
|
||||
}, [isAuthenticated, client, linkSegments, setViewMode, setSelectedDate, openEditModal, tDeepLink]);
|
||||
};
|
||||
const applyCalendarDeepLinkRef = useRef(applyCalendarDeepLink);
|
||||
applyCalendarDeepLinkRef.current = applyCalendarDeepLink;
|
||||
|
||||
// The permalink for what the calendar is currently showing. Suppressed in
|
||||
// the Pro shell, where /pro owns the address bar.
|
||||
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;
|
||||
applyCalendarDeepLinkRef.current(link);
|
||||
}, [isAuthenticated, client, linkSegments]);
|
||||
|
||||
// Pro shell only: this surface stays mounted for the whole session, so links
|
||||
// arriving after mount are delivered live instead of being parked forever.
|
||||
// Embedded-only: during a cold-load redirect the standard instance renders
|
||||
// briefly and must not steal the link parked for the Pro one.
|
||||
useEffect(() => {
|
||||
if (!isEmbedded) return;
|
||||
return subscribePendingDeepLink('calendar', (segments) => {
|
||||
const link = parseCalendarPath(segments, new URLSearchParams(window.location.search));
|
||||
if (link) applyCalendarDeepLinkRef.current(link);
|
||||
});
|
||||
}, [isEmbedded]);
|
||||
|
||||
// The permalink for what the calendar is currently showing. In the Pro
|
||||
// shell only the focused tab writes the address bar; the standard instance
|
||||
// that renders while Pro takes over a route stays silent.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const eventLinkId = showEventModal && editEvent ? editEvent.id : null;
|
||||
const calendarLinkPath = appPath(buildCalendarPath({
|
||||
view: normalizedViewMode,
|
||||
date: selectedDate,
|
||||
eventId: eventLinkId,
|
||||
accountId: eventLinkId ? editEvent?.accountId : null,
|
||||
}));
|
||||
useDeepLinkUrl(
|
||||
isEmbedded || proInterfaceActive
|
||||
? null
|
||||
: appPath(buildCalendarPath({
|
||||
view: normalizedViewMode,
|
||||
date: selectedDate,
|
||||
eventId: eventLinkId,
|
||||
accountId: eventLinkId ? editEvent?.accountId : null,
|
||||
})),
|
||||
isEmbedded
|
||||
? (isFocusedProTab ? calendarLinkPath : null)
|
||||
: proInterfaceActive ? null : calendarLinkPath,
|
||||
);
|
||||
|
||||
const handleEditFromDetail = useCallback(() => {
|
||||
|
||||
@@ -31,14 +31,15 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
|
||||
import { useProMultiAccountContacts } from "@/hooks/use-pro-multi-account-contacts";
|
||||
import { ResizeHandle } from "@/components/layout/resize-handle";
|
||||
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 { appPath, buildContactsPath, parseContactsPath, type ContactDeepLink } from "@/lib/deep-links";
|
||||
import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
|
||||
@@ -181,15 +182,9 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
|
||||
// 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 segments = linkSegments ?? consumePendingDeepLink('contacts') ?? [];
|
||||
const link = parseContactsPath(
|
||||
segments,
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
intentAppliedRef.current = true;
|
||||
if (!link) return;
|
||||
// The applier is held in a ref so the live-delivery subscription below (Pro
|
||||
// shell) always calls the copy closing over the current setters.
|
||||
const applyContactsDeepLink = (link: ContactDeepLink) => {
|
||||
if (link.fromEmail) setReturnToEmail(true);
|
||||
if (link.kind === 'contact') {
|
||||
setSelectedContact(link.id);
|
||||
@@ -199,18 +194,47 @@ export function ContactsApp({ linkSegments }: ContactsAppProps = {}) {
|
||||
setSelectedContact(null);
|
||||
setView('create');
|
||||
}
|
||||
};
|
||||
const applyContactsDeepLinkRef = useRef(applyContactsDeepLink);
|
||||
applyContactsDeepLinkRef.current = applyContactsDeepLink;
|
||||
|
||||
useEffect(() => {
|
||||
if (intentAppliedRef.current) return;
|
||||
const segments = linkSegments ?? consumePendingDeepLink('contacts') ?? [];
|
||||
const link = parseContactsPath(
|
||||
segments,
|
||||
new URLSearchParams(searchParams.toString()),
|
||||
);
|
||||
intentAppliedRef.current = true;
|
||||
if (!link) return;
|
||||
applyContactsDeepLinkRef.current(link);
|
||||
}, [searchParams, linkSegments, setSelectedContact]);
|
||||
|
||||
// The permalink for the contact on screen. Suppressed inside the Pro shell,
|
||||
// where /pro owns the address bar.
|
||||
// Pro shell only: this surface stays mounted for the whole session, so links
|
||||
// arriving after mount are delivered live instead of being parked forever.
|
||||
// Embedded-only: during a cold-load redirect the standard instance renders
|
||||
// briefly and must not steal the link parked for the Pro one.
|
||||
useEffect(() => {
|
||||
if (!isEmbedded) return;
|
||||
return subscribePendingDeepLink('contacts', (segments) => {
|
||||
const link = parseContactsPath(segments, new URLSearchParams(window.location.search));
|
||||
if (link) applyContactsDeepLinkRef.current(link);
|
||||
});
|
||||
}, [isEmbedded]);
|
||||
|
||||
// The permalink for the contact on screen. In the Pro shell only the
|
||||
// focused tab writes the address bar; the standard instance that renders
|
||||
// while Pro takes over a route stays silent.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const contactsLinkPath = appPath(buildContactsPath({
|
||||
contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
|
||||
editing: view === 'edit',
|
||||
}));
|
||||
useDeepLinkUrl(
|
||||
isEmbedded || proInterfaceActive
|
||||
? null
|
||||
: appPath(buildContactsPath({
|
||||
contactId: view === 'detail' || view === 'edit' ? selectedContactId : null,
|
||||
editing: view === 'edit',
|
||||
})),
|
||||
isEmbedded
|
||||
? (isFocusedProTab ? contactsLinkPath : null)
|
||||
: proInterfaceActive ? null : contactsLinkPath,
|
||||
);
|
||||
|
||||
// Intercept browser refresh gestures (F5, Ctrl/Cmd+R, pull-to-refresh)
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal";
|
||||
import { InlineAppView } from "@/components/layout/inline-app-view";
|
||||
import { useSidebarApps } from "@/hooks/use-sidebar-apps";
|
||||
import { useIsEmbedded } from "@/hooks/use-is-embedded";
|
||||
import { useIsFocusedProTab } from "@/hooks/use-pane-context";
|
||||
import { useIsMobile } from "@/hooks/use-media-query";
|
||||
import { useRefreshGesture } from "@/hooks/use-refresh-gesture";
|
||||
import { usePolicyStore } from "@/stores/policy-store";
|
||||
@@ -31,7 +32,7 @@ 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 { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useDeepLinkUrl } from "@/hooks/use-deep-link-url";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
|
||||
@@ -212,6 +213,24 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) {
|
||||
return link;
|
||||
}, []);
|
||||
|
||||
// Pro shell only: this surface stays mounted for the whole session, so links
|
||||
// arriving after mount are delivered live instead of being parked forever.
|
||||
// The bootstrap has already run by then - walk the drive directly and let
|
||||
// the preview effect below claim `pendingPreviewRef` once the listing lands.
|
||||
// Embedded-only: during a cold-load redirect the standard instance renders
|
||||
// briefly and must not steal the link parked for the Pro one.
|
||||
const navigateByPathRef = useRef(navigateByPath);
|
||||
navigateByPathRef.current = navigateByPath;
|
||||
useEffect(() => {
|
||||
if (!isEmbedded) return;
|
||||
return subscribePendingDeepLink('files', (segments) => {
|
||||
const link = parseFilesPath(segments, new URLSearchParams(window.location.search));
|
||||
if (!link) return;
|
||||
if (link.preview) pendingPreviewRef.current = link.preview;
|
||||
void navigateByPathRef.current(link.path);
|
||||
});
|
||||
}, [isEmbedded]);
|
||||
|
||||
// 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
|
||||
@@ -480,13 +499,16 @@ export function FilesApp({ linkSegments }: FilesAppProps = {}) {
|
||||
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).
|
||||
// The permalink for the folder on screen. In the Pro shell only the focused
|
||||
// tab writes the address bar; the standard instance that renders while Pro
|
||||
// takes over a route stays silent.
|
||||
const proInterfaceActive = useProInterfaceActive();
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const filesLinkPath = appPath(buildFilesPath(currentPath, previewFile));
|
||||
useDeepLinkUrl(
|
||||
isEmbedded || proInterfaceActive
|
||||
? null
|
||||
: appPath(buildFilesPath(currentPath, previewFile)),
|
||||
isEmbedded
|
||||
? (isFocusedProTab ? filesLinkPath : null)
|
||||
: proInterfaceActive ? null : filesLinkPath,
|
||||
);
|
||||
|
||||
const handleToggleDetails = useCallback(() => {
|
||||
|
||||
@@ -105,7 +105,7 @@ import {
|
||||
resolveFolderRef,
|
||||
type MailDeepLink,
|
||||
} from "@/lib/deep-links";
|
||||
import { consumePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-link-handoff";
|
||||
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
|
||||
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||
|
||||
@@ -1277,6 +1277,17 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
}
|
||||
|
||||
if (link.kind === 'message') {
|
||||
// In the Pro shell a message permalink opens as its own fullscreen
|
||||
// email tab - the tab body fetches the message and retitles itself.
|
||||
if (isEmbedded) {
|
||||
useProTabStore.getState().openEmailTab({
|
||||
accountId: '',
|
||||
emailId: link.id,
|
||||
mailboxId: null,
|
||||
title: t('common.loading'),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setLoadingEmail(true);
|
||||
try {
|
||||
const email = await fetchEmailContent(activeClient, link.id);
|
||||
@@ -1337,6 +1348,19 @@ export function MailApp({ linkSegments }: MailAppProps = {}) {
|
||||
const applyMailDeepLinkRef = useRef(applyMailDeepLink);
|
||||
applyMailDeepLinkRef.current = applyMailDeepLink;
|
||||
|
||||
// Pro shell only: this MailApp stays mounted for the whole session, so the
|
||||
// mount-time consume below never sees a link that arrives later (an in-app
|
||||
// navigation ProInterfaceRedirect intercepts). Subscribe for live delivery.
|
||||
// Embedded-only: during a cold-load redirect the standard instance renders
|
||||
// briefly and must not steal the link parked for the Pro one.
|
||||
useEffect(() => {
|
||||
if (!isEmbedded) return;
|
||||
return subscribePendingDeepLink('mail', (segments) => {
|
||||
const link = parseMailPath(segments, new URLSearchParams(window.location.search));
|
||||
if (link) void applyMailDeepLinkRef.current(link);
|
||||
});
|
||||
}, [isEmbedded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deepLinkHandledRef.current) return;
|
||||
if (!isAuthenticated || !client) return;
|
||||
|
||||
@@ -48,10 +48,13 @@ export function ProInterfaceRedirect() {
|
||||
if (!proInterface || !isDesktop) return;
|
||||
const match = matchSurface(pathname);
|
||||
if (!match) return;
|
||||
// Focus the tab before handing over the link: when the surface is already
|
||||
// mounted the handoff delivers live, and its handler may itself open a
|
||||
// tab (a message permalink opens an email tab) that must keep focus.
|
||||
useProTabStore.getState().openTab(SURFACE_TO_TAB[match.surface]);
|
||||
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]);
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ 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 { appPath, buildSettingsPath, parseSettingsPath } from '@/lib/deep-links';
|
||||
import { consumePendingDeepLink } from '@/lib/deep-link-handoff';
|
||||
import { consumePendingDeepLink, subscribePendingDeepLink } 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';
|
||||
@@ -85,6 +85,7 @@ import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal';
|
||||
import { InlineAppView } from '@/components/layout/inline-app-view';
|
||||
import { useSidebarApps } from '@/hooks/use-sidebar-apps';
|
||||
import { useIsEmbedded } from '@/hooks/use-is-embedded';
|
||||
import { useIsFocusedProTab } from '@/hooks/use-pane-context';
|
||||
import { ResizeHandle } from '@/components/layout/resize-handle';
|
||||
import { useConfig } from '@/hooks/use-config';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
@@ -434,6 +435,16 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
|
||||
const tab = tabFromDeepLink(consumePendingDeepLink('settings'));
|
||||
if (tab) setActiveTab(tab);
|
||||
}, [linkSegments]);
|
||||
// Pro shell only: this surface stays mounted for the whole session, so links
|
||||
// arriving later (e.g. the sidebar's folder-settings gear) are delivered
|
||||
// live instead of being parked for a mount that already happened.
|
||||
useEffect(() => {
|
||||
if (!isEmbedded) return;
|
||||
return subscribePendingDeepLink('settings', (segments) => {
|
||||
const tab = tabFromDeepLink(segments);
|
||||
if (tab) setActiveTab(tab);
|
||||
});
|
||||
}, [isEmbedded]);
|
||||
const [mobileShowContent, setMobileShowContent] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [pendingHighlight, setPendingHighlight] = useState<{ tab: SettingsTabId; label: string; pluginId?: string } | null>(null);
|
||||
@@ -652,10 +663,14 @@ export function SettingsApp({ linkSegments }: SettingsAppProps = {}) {
|
||||
// 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();
|
||||
const isFocusedProTab = useIsFocusedProTab();
|
||||
const settingsLinkPath = appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab));
|
||||
useDeepLinkUrl(
|
||||
!isAuthenticated || isEmbedded || proInterfaceActive
|
||||
!isAuthenticated
|
||||
? null
|
||||
: appPath(buildSettingsPath(!isDesktop && !mobileShowContent ? null : activeTab)),
|
||||
: isEmbedded
|
||||
? (isFocusedProTab ? settingsLinkPath : null)
|
||||
: proInterfaceActive ? null : settingsLinkPath,
|
||||
);
|
||||
|
||||
// Park the content-pane scroll offset when the Pro toggle flips (the flip
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Keeps the address bar in step with what a surface is showing (#733), so the
|
||||
@@ -11,20 +11,19 @@ import { useEffect, useRef } from "react";
|
||||
* 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).
|
||||
* Pass `null` to leave the URL alone (an unfocused Pro tab, whose focused
|
||||
* sibling owns the address bar). Dedupe is against the *actual* location, not
|
||||
* a remembered last write: in the Pro shell several surfaces take turns
|
||||
* writing the URL, so what this hook wrote last says nothing about what the
|
||||
* bar currently shows.
|
||||
*
|
||||
* 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]);
|
||||
|
||||
@@ -16,6 +16,19 @@ export function usePaneId(): ProPaneId | null {
|
||||
return useContext(PaneIdContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the current subtree is the *focused* Pro tab: the active tab of
|
||||
* the focused pane. Exactly one tab body sees `true` at any time, which is
|
||||
* what lets that tab own shared, single-instance concerns - currently the
|
||||
* address bar, where the focused tab's permalink is reflected. Always false
|
||||
* outside the Pro shell.
|
||||
*/
|
||||
export const ProTabFocusContext = createContext<boolean>(false);
|
||||
|
||||
export function useIsFocusedProTab(): boolean {
|
||||
return useContext(ProTabFocusContext);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the current subtree renders inside a Pro pane.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
setPendingDeepLink,
|
||||
consumePendingDeepLink,
|
||||
subscribePendingDeepLink,
|
||||
clearPendingDeepLinks,
|
||||
} from '@/lib/deep-link-handoff';
|
||||
|
||||
describe('deep-link handoff', () => {
|
||||
beforeEach(() => {
|
||||
clearPendingDeepLinks();
|
||||
});
|
||||
|
||||
it('parks segments and yields them exactly once', () => {
|
||||
setPendingDeepLink('mail', ['message', 'abc']);
|
||||
expect(consumePendingDeepLink('mail')).toEqual(['message', 'abc']);
|
||||
expect(consumePendingDeepLink('mail')).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps surfaces separate', () => {
|
||||
setPendingDeepLink('mail', ['message', 'abc']);
|
||||
setPendingDeepLink('settings', ['folders']);
|
||||
expect(consumePendingDeepLink('settings')).toEqual(['folders']);
|
||||
expect(consumePendingDeepLink('mail')).toEqual(['message', 'abc']);
|
||||
});
|
||||
|
||||
it('delivers live to a subscribed surface instead of parking', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribePendingDeepLink('mail', listener);
|
||||
|
||||
setPendingDeepLink('mail', ['message', 'abc']);
|
||||
|
||||
expect(listener).toHaveBeenCalledWith(['message', 'abc']);
|
||||
// Delivered, not parked: a later mount finds nothing.
|
||||
expect(consumePendingDeepLink('mail')).toBeNull();
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('a listener only receives its own surface', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribePendingDeepLink('mail', listener);
|
||||
|
||||
setPendingDeepLink('settings', ['folders']);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(consumePendingDeepLink('settings')).toEqual(['folders']);
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('unsubscribing restores parking', () => {
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribePendingDeepLink('mail', listener);
|
||||
unsubscribe();
|
||||
|
||||
setPendingDeepLink('mail', ['folder', 'inbox']);
|
||||
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
expect(consumePendingDeepLink('mail')).toEqual(['folder', 'inbox']);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AppSurface } from '@/lib/deep-links';
|
||||
|
||||
/**
|
||||
* One-shot handoff for a deep link that has to survive a route change (#733).
|
||||
* 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
|
||||
@@ -9,12 +9,30 @@ import type { AppSurface } from '@/lib/deep-links';
|
||||
* parks the segments here, opens the right tab, and the surface picks them up
|
||||
* on mount - exactly like `protocol-handlers/session` does for `mailto:`.
|
||||
*
|
||||
* Surfaces in the Pro shell stay mounted for the whole session, so a link that
|
||||
* arrives *while the shell is already running* (an in-app link to
|
||||
* `/settings/folders`, a `mailto:`-style handoff, ...) would be parked and
|
||||
* never seen - the mount-time consume has long since run. For that case a
|
||||
* mounted surface subscribes here: when a listener exists, `setPendingDeepLink`
|
||||
* delivers the segments to it directly instead of parking them.
|
||||
*
|
||||
* 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[]>();
|
||||
|
||||
type PendingDeepLinkListener = (segments: string[]) => void;
|
||||
|
||||
const listeners = new Map<AppSurface, Set<PendingDeepLinkListener>>();
|
||||
|
||||
export function setPendingDeepLink(surface: AppSurface, segments: string[]): void {
|
||||
const live = listeners.get(surface);
|
||||
if (live && live.size > 0) {
|
||||
// A mounted surface is listening - deliver instead of parking, so links
|
||||
// arriving while the Pro shell is already running still land.
|
||||
for (const listener of [...live]) listener(segments);
|
||||
return;
|
||||
}
|
||||
pending.set(surface, segments);
|
||||
}
|
||||
|
||||
@@ -26,6 +44,28 @@ export function consumePendingDeepLink(surface: AppSurface): string[] | null {
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live delivery for an already-mounted surface (the Pro shell keeps surfaces
|
||||
* mounted for the whole session). Returns the unsubscribe function. Only the
|
||||
* embedded (Pro) instance of a surface should subscribe - during a cold-load
|
||||
* redirect the standard instance briefly renders too and must not steal the
|
||||
* link parked for the Pro instance that mounts right after.
|
||||
*/
|
||||
export function subscribePendingDeepLink(
|
||||
surface: AppSurface,
|
||||
listener: PendingDeepLinkListener,
|
||||
): () => void {
|
||||
let set = listeners.get(surface);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(surface, set);
|
||||
}
|
||||
set.add(listener);
|
||||
return () => {
|
||||
set.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function clearPendingDeepLinks(): void {
|
||||
pending.clear();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user