Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/components/app/app-sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof ContextMenu.Trigger>
Expand All @@ -716,6 +721,7 @@ const SidebarDropdownLink = (
<MenuItem text="Open fullscreen" onClick={openFullscreen} />
</Show>
<MenuItem text="Open in current split" onClick={openInCurrentSplit} />
<MenuItem text="Open in new tab" onClick={openInNewTab} />
</ContextMenuContent>
</ContextMenu.Portal>
</ContextMenu>
Expand Down Expand Up @@ -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 (
<ContextMenu onOpenChange={props.onOpenChange}>
<ContextMenu.Trigger class="w-full h-7">
Expand All @@ -1753,6 +1770,7 @@ const SidebarOpenInSplitMenu = (props: SidebarOpenInSplitMenuProps) => {
<MenuItem text="Open fullscreen" onClick={openFullscreen} />
</Show>
<MenuItem text="Open in current split" onClick={openInCurrentSplit} />
<MenuItem text="Open in new tab" onClick={openInNewTab} />
</ContextMenuContent>
</ContextMenu.Portal>
</ContextMenu>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/app/split-layout/layoutManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())];
}
Expand Down
39 changes: 34 additions & 5 deletions apps/web/src/components/app/split-layout/layoutUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';

Expand Down Expand Up @@ -56,6 +59,32 @@ export function decodePairs(segments: string[]): SplitContent[] {
return pairs.length ? pairs : [{ type: 'component', id: LIST_VIEW_ID.inbox }];
}

/**
* The absolute `/app/<type>/<id>` 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<SplitContent>): string[] {
return splits.flatMap((s) => [
// Use the alias type if available, otherwise use the base type
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
};
Expand Down Expand Up @@ -294,6 +301,7 @@ function ChannelRow(props: {
text="Open in current split"
onClick={openInCurrentSplit}
/>
<MenuItem text="Open in new tab" onClick={openInNewTab} />
</MenuGroup>
<Show when={isUnread()}>
<MenuSeparator />
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/features/favorites/sidebar/favorites-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -397,6 +398,7 @@ const FavoriteRow = (props: {
globalSplitManager()?.returnFocus();
return split;
};
const openInNewTab = () => openSplitContentInNewTab(content());
const markAllAsRead = () => {
void notificationSource.bulkMarkAsRead(props.notifications());
};
Expand Down Expand Up @@ -490,6 +492,7 @@ const FavoriteRow = (props: {
text="Open in current split"
onClick={openInCurrentSplit}
/>
<MenuItem text="Open in new tab" onClick={openInNewTab} />
</MenuGroup>
<Show when={props.notifications().length > 0}>
<MenuSeparator />
Expand Down
Loading