diff --git a/apps/web/src/components/app/app-sidebar/sidebar.tsx b/apps/web/src/components/app/app-sidebar/sidebar.tsx index 5518bd3ac3d..da16182fe11 100644 --- a/apps/web/src/components/app/app-sidebar/sidebar.tsx +++ b/apps/web/src/components/app/app-sidebar/sidebar.tsx @@ -40,6 +40,7 @@ import type { SplitContent, SplitHandle, } from '@components/app/split-layout/layoutManager'; +import { openSplitContentInNewTab } from '@components/app/split-layout/layoutUtils'; import { useHasPaidAccess } from '@core/auth'; import { useLogout } from '@core/auth/logout'; import { ContextMenuContent, MenuItem } from '@core/component/ContextMenu'; @@ -699,6 +700,10 @@ const SidebarDropdownLink = ( if (props.id === 'search' && handle) requestSearchFocus(handle.id); globalSplitManager()?.returnFocus(); }; + const openInNewTab = () => { + analytics.track('sidebar_click', { view: props.id, target: 'new-tab' }); + openSplitContentInNewTab({ type: 'component', id: props.id }); + }; const ContextMenuTriggerItem = ( triggerProps: ComponentProps @@ -716,6 +721,7 @@ const SidebarDropdownLink = ( + @@ -1736,6 +1742,17 @@ const SidebarOpenInSplitMenu = (props: SidebarOpenInSplitMenuProps) => { globalSplitManager()?.returnFocus(); }; + // The new tab loads from the URL, so it can't run `onOpened` — content + // params and the scoping those callbacks do (e.g. an Email row's inbox + // filter) don't survive; the view opens in its default state. + const openInNewTab = () => { + analytics.track('sidebar_click', { + view: props.content().id, + target: 'new-tab', + }); + openSplitContentInNewTab(props.content()); + }; + return ( @@ -1753,6 +1770,7 @@ const SidebarOpenInSplitMenu = (props: SidebarOpenInSplitMenuProps) => { + diff --git a/apps/web/src/components/app/split-layout/layoutManager.ts b/apps/web/src/components/app/split-layout/layoutManager.ts index f5d46400f8a..1aa63f905e4 100644 --- a/apps/web/src/components/app/split-layout/layoutManager.ts +++ b/apps/web/src/components/app/split-layout/layoutManager.ts @@ -112,7 +112,7 @@ function getAliasOrType(content: SplitContent): string { * is claimed by the split layout like any other split — so there's no reason * to keep the tab out of the URL. */ -function contentUrlSegments(content: SplitContent): string[] { +export function contentUrlSegments(content: SplitContent): string[] { if (content.type === 'component' && content.id === 'settings') { return ['settings', settingsTabToSlug(activeTabId())]; } diff --git a/apps/web/src/components/app/split-layout/layoutUtils.ts b/apps/web/src/components/app/split-layout/layoutUtils.ts index 413f2bbf506..b91bd0fbdc3 100644 --- a/apps/web/src/components/app/split-layout/layoutUtils.ts +++ b/apps/web/src/components/app/split-layout/layoutUtils.ts @@ -2,6 +2,8 @@ import { LIST_VIEW_ID } from '@app/constants/list-views'; import { globalSplitManager } from '@app/signal/splitLayout'; import type { BlockAlias, BlockName } from '@core/block'; import { isBlockAlias, resolveBlockAlias } from '@core/constant/allBlocks'; +import { openExternalUrl } from '@core/util/url'; +import { getWebOrigin } from '@core/util/webOrigin'; import { createCallback } from '@solid-primitives/rootless'; import { type Accessor, @@ -11,11 +13,12 @@ import { useContext, } from 'solid-js'; import { SplitLayoutContext, SplitPanelContext } from './context'; -import type { - SplitContent, - SplitContentType, - SplitHandle, - SplitManager, +import { + contentUrlSegments, + type SplitContent, + type SplitContentType, + type SplitHandle, + type SplitManager, } from './layoutManager'; import type { CollapsibleItemInput } from './utils/createPriorityCollapser'; @@ -56,6 +59,32 @@ export function decodePairs(segments: string[]): SplitContent[] { return pairs.length ? pairs : [{ type: 'component', id: LIST_VIEW_ID.inbox }]; } +/** + * The absolute `/app//` web link for a piece of split content. A URL + * carries no layout beyond the pairs it lists, so opening this one lands the + * content as the only split — i.e. fullscreen. + * + * The `/app` prefix is hardcoded (as in `buildSimpleEntityUrl`) rather than + * taken from `ROUTER_BASE`: native builds route on `/` but still address the + * web app by its real origin, and `parseInternalAppLink` strips the prefix back + * off when such a link is handled in-app. + */ +export function splitContentUrl(content: SplitContent): string { + return `${getWebOrigin()}/app/${contentUrlSegments(content).join('/')}`; +} + +/** + * Open split content in a new, focused browser tab, showing it fullscreen. + * + * Content params (a prefiltered list, say) are not part of the URL, so the new + * tab opens the view in its default state. Inside the native shell there are no + * tabs: `openExternalUrl`'s Macro-link interceptor routes the link into the + * current window instead of handing it to the system browser. + */ +export function openSplitContentInNewTab(content: SplitContent) { + openExternalUrl(splitContentUrl(content)); +} + function _encodePairs(splits: ReadonlyArray): string[] { return splits.flatMap((s) => [ // Use the alias type if available, otherwise use the base type diff --git a/apps/web/src/components/app/split-layout/tests/splitContentUrl.test.ts b/apps/web/src/components/app/split-layout/tests/splitContentUrl.test.ts new file mode 100644 index 00000000000..4e3386213fe --- /dev/null +++ b/apps/web/src/components/app/split-layout/tests/splitContentUrl.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it, vi } from 'vitest'; +import { splitContentUrl } from '../layoutUtils'; + +vi.mock('../componentRegistry', () => ({ + resolveComponent: vi.fn(), +})); + +vi.mock('@core/constant/allBlocks', () => ({ + isBlockAlias: vi.fn(() => false), + resolveBlockAlias: vi.fn((type: string) => type), +})); + +vi.mock('@core/util/webOrigin', () => ({ + getWebOrigin: () => 'https://macro.com', +})); + +vi.mock('@core/signal/settingsTab', () => ({ + activeTabId: () => 'account', +})); + +vi.mock('@core/constant/settingsTabsConfig', () => ({ + settingsTabToSlug: (tab: string) => tab, +})); + +describe('splitContentUrl', () => { + it('addresses a list view by its component id', () => { + expect(splitContentUrl({ type: 'component', id: 'tasks' })).toBe( + 'https://macro.com/app/component/tasks' + ); + }); + + it('addresses a block by type and id', () => { + expect(splitContentUrl({ type: 'channel', id: 'channel-123' })).toBe( + 'https://macro.com/app/channel/channel-123' + ); + }); + + it('uses the alias type when the content carries one', () => { + expect( + splitContentUrl({ + type: 'task', + id: 'doc-1', + aliasContext: { alias: 'task', baseType: 'md' }, + }) + ).toBe('https://macro.com/app/task/doc-1'); + }); + + it('serializes settings with its active tab, as the URL sync does', () => { + expect(splitContentUrl({ type: 'component', id: 'settings' })).toBe( + 'https://macro.com/app/settings/account' + ); + }); +}); diff --git a/apps/web/src/features/channel/sidebar/channels-recent-widget.tsx b/apps/web/src/features/channel/sidebar/channels-recent-widget.tsx index 01ad1b194fc..d53ac314912 100644 --- a/apps/web/src/features/channel/sidebar/channels-recent-widget.tsx +++ b/apps/web/src/features/channel/sidebar/channels-recent-widget.tsx @@ -16,6 +16,7 @@ import { useGlobalNotificationSource, } from '@components/app/GlobalAppState'; import { useSplitLayout } from '@components/app/split-layout/layout'; +import { openSplitContentInNewTab } from '@components/app/split-layout/layoutUtils'; import { ContextMenuContent, MenuGroup, @@ -214,6 +215,12 @@ function ChannelRow(props: { openChannel(props.channel, true); }; + // Unlike the split actions, this opens the channel at its latest message + // rather than at the first unread one: the URL addresses the channel, and a + // message anchor is not part of it. + const openInNewTab = () => + openSplitContentInNewTab({ type: 'channel', id: entity().id }); + const markAllAsRead = () => { void notificationSource.bulkMarkAsRead(props.channel.unread); }; @@ -294,6 +301,7 @@ function ChannelRow(props: { text="Open in current split" onClick={openInCurrentSplit} /> + diff --git a/apps/web/src/features/favorites/sidebar/favorites-section.tsx b/apps/web/src/features/favorites/sidebar/favorites-section.tsx index 81e3a496a8f..08373c81f2b 100644 --- a/apps/web/src/features/favorites/sidebar/favorites-section.tsx +++ b/apps/web/src/features/favorites/sidebar/favorites-section.tsx @@ -14,6 +14,7 @@ import { useGlobalNotificationSource, } from '@components/app/GlobalAppState'; import { useSplitLayout } from '@components/app/split-layout/layout'; +import { openSplitContentInNewTab } from '@components/app/split-layout/layoutUtils'; import { ContextMenuContent, MenuGroup, @@ -397,6 +398,7 @@ const FavoriteRow = (props: { globalSplitManager()?.returnFocus(); return split; }; + const openInNewTab = () => openSplitContentInNewTab(content()); const markAllAsRead = () => { void notificationSource.bulkMarkAsRead(props.notifications()); }; @@ -490,6 +492,7 @@ const FavoriteRow = (props: { text="Open in current split" onClick={openInCurrentSplit} /> + 0}>