{
+ const t = useI18n();
+ const openInAppService = useService(OpenInAppService);
+ const currentOpenInAppMode = useLiveData(openInAppService.openLinkMode$);
+
+ const options = useMemo(
+ () =>
+ Object.values(OpenLinkMode).map(mode => ({
+ label:
+ t.t(`com.affine.setting.appearance.open-in-app.${mode}`) ||
+ `com.affine.setting.appearance.open-in-app.${mode}`,
+ value: mode,
+ })),
+ [t]
+ );
+
+ return (
+ {
+ return (
+ openInAppService.setOpenLinkMode(option.value)}
+ data-selected={currentOpenInAppMode === option.value}
+ >
+ {option.label}
+
+ );
+ })}
+ contentOptions={{
+ className: styles.menu,
+ align: 'end',
+ }}
+ >
+
+ {options.find(option => option.value === currentOpenInAppMode)?.label}
+
+
+ );
+};
diff --git a/packages/frontend/core/src/components/affine/subscription-landing/index.tsx b/packages/frontend/core/src/components/affine/subscription-landing/index.tsx
index 1129be8301..4138a6388f 100644
--- a/packages/frontend/core/src/components/affine/subscription-landing/index.tsx
+++ b/packages/frontend/core/src/components/affine/subscription-landing/index.tsx
@@ -18,14 +18,14 @@ const UpgradeSuccessLayout = ({
const t = useI18n();
const [params] = useSearchParams();
- const { jumpToIndex, openInApp } = useNavigateHelper();
+ const { jumpToIndex, jumpToOpenInApp } = useNavigateHelper();
const openAffine = useCallback(() => {
if (params.get('scheme')) {
- openInApp(params.get('scheme') ?? 'affine', 'bring-to-front');
+ jumpToOpenInApp('bring-to-front');
} else {
jumpToIndex();
}
- }, [jumpToIndex, openInApp, params]);
+ }, [jumpToIndex, jumpToOpenInApp, params]);
const subtitle = (
diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx
index d1b04b6f56..abe716f5e6 100644
--- a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx
+++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx
@@ -25,7 +25,7 @@ import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/workspace/detail-page/use-header-responsive';
import { DocInfoService } from '@affine/core/modules/doc-info';
import { EditorService } from '@affine/core/modules/editor';
-import { getOpenUrlInDesktopAppLink } from '@affine/core/modules/open-in-app/utils';
+import { OpenInAppService } from '@affine/core/modules/open-in-app/services';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { ViewService } from '@affine/core/modules/workbench/services/view';
import { WorkspaceFlavour } from '@affine/env/workspace';
@@ -52,6 +52,7 @@ import {
FeatureFlagService,
useLiveData,
useService,
+ useServiceOptional,
WorkspaceService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
@@ -92,6 +93,7 @@ export const PageHeaderMenuButton = ({
const enableSnapshotImportExport = useLiveData(
featureFlagService.flags.enable_snapshot_import_export.$
);
+ const openInAppService = useServiceOptional(OpenInAppService);
const { favorite, toggleFavorite } = useFavorite(pageId);
@@ -265,11 +267,8 @@ export const PageHeaderMenuButton = ({
);
const onOpenInDesktop = useCallback(() => {
- const url = getOpenUrlInDesktopAppLink(window.location.href, true);
- if (url) {
- window.open(url, '_blank');
- }
- }, []);
+ openInAppService?.showOpenInAppPage();
+ }, [openInAppService]);
const EditMenu = (
<>
@@ -376,7 +375,8 @@ export const PageHeaderMenuButton = ({
data-testid="editor-option-menu-delete"
onSelect={handleOpenTrashModal}
/>
- {BUILD_CONFIG.isWeb ? (
+ {BUILD_CONFIG.isWeb &&
+ workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ? (
}
data-testid="editor-option-menu-link"
diff --git a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts
index 3d69a67c7a..a6a5927be7 100644
--- a/packages/frontend/core/src/components/hooks/use-navigate-helper.ts
+++ b/packages/frontend/core/src/components/hooks/use-navigate-helper.ts
@@ -1,4 +1,5 @@
import { toURLSearchParams } from '@affine/core/modules/navigation';
+import { getOpenUrlInDesktopAppLink } from '@affine/core/modules/open-in-app';
import type { DocMode } from '@blocksuite/affine/blocks';
import { createContext, useCallback, useContext, useMemo } from 'react';
import type { NavigateFunction, NavigateOptions } from 'react-router-dom';
@@ -159,10 +160,16 @@ export function useNavigateHelper() {
[navigate]
);
- const openInApp = useCallback(
- (scheme: string, path: string) => {
- const encodedUrl = encodeURIComponent(`${scheme}://${path}`);
- return navigate(`/open-app/url?scheme=${scheme}&url=${encodedUrl}`);
+ const jumpToOpenInApp = useCallback(
+ (url: string, newTab = true) => {
+ const deeplink = getOpenUrlInDesktopAppLink(url, newTab);
+
+ if (!deeplink) {
+ return;
+ }
+
+ const encodedUrl = encodeURIComponent(deeplink);
+ return navigate(`/open-app/url?url=${encodedUrl}`);
},
[navigate]
);
@@ -189,7 +196,7 @@ export function useNavigateHelper() {
jumpToCollections,
jumpToTags,
jumpToTag,
- openInApp,
+ jumpToOpenInApp,
jumpToImportTemplate,
}),
[
@@ -204,7 +211,7 @@ export function useNavigateHelper() {
jumpToCollections,
jumpToTags,
jumpToTag,
- openInApp,
+ jumpToOpenInApp,
jumpToImportTemplate,
]
);
diff --git a/packages/frontend/core/src/components/root-app-sidebar/index.tsx b/packages/frontend/core/src/components/root-app-sidebar/index.tsx
index 36e6b23202..0538ba2722 100644
--- a/packages/frontend/core/src/components/root-app-sidebar/index.tsx
+++ b/packages/frontend/core/src/components/root-app-sidebar/index.tsx
@@ -5,11 +5,11 @@ import {
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
import {
AddPageButton,
- AppDownloadButton,
AppSidebar,
CategoryDivider,
MenuItem,
MenuLinkItem,
+ OpenInAppCard,
QuickSearchInput,
SidebarContainer,
SidebarScrollableContainer,
@@ -190,7 +190,7 @@ export const RootAppSidebar = (): ReactElement => {
- {BUILD_CONFIG.isElectron ? : }
+ {BUILD_CONFIG.isElectron ? : }
);
diff --git a/packages/frontend/core/src/desktop/pages/open-app.css.ts b/packages/frontend/core/src/desktop/pages/open-app.css.ts
index d46a5cbe14..6751571559 100644
--- a/packages/frontend/core/src/desktop/pages/open-app.css.ts
+++ b/packages/frontend/core/src/desktop/pages/open-app.css.ts
@@ -33,7 +33,12 @@ export const topNavLink = style({
textDecoration: 'none',
padding: '4px 18px',
});
-export const tryAgainLink = style({
+
+export const promptLinks = style({
+ display: 'flex',
+ columnGap: 16,
+});
+export const promptLink = style({
color: cssVar('linkColor'),
fontWeight: 500,
textDecoration: 'none',
@@ -49,3 +54,11 @@ export const prompt = style({
marginTop: 20,
marginBottom: 12,
});
+export const editSettingsLink = style({
+ fontWeight: 500,
+ textDecoration: 'none',
+ color: cssVar('linkColor'),
+ fontSize: cssVar('fontSm'),
+ position: 'absolute',
+ bottom: 48,
+});
diff --git a/packages/frontend/core/src/desktop/pages/open-app.tsx b/packages/frontend/core/src/desktop/pages/open-app.tsx
index 3338925a0c..e10deb4678 100644
--- a/packages/frontend/core/src/desktop/pages/open-app.tsx
+++ b/packages/frontend/core/src/desktop/pages/open-app.tsx
@@ -1,134 +1,32 @@
-import { Button } from '@affine/component/ui/button';
-import {
- appIconMap,
- appNames,
- appSchemes,
- type Channel,
- schemeToChannel,
-} from '@affine/core/modules/open-in-app/constant';
+import { OpenInAppPage } from '@affine/core/modules/open-in-app/views/open-in-app-page';
+import { appSchemes } from '@affine/core/utils';
import type { GetCurrentUserQuery } from '@affine/graphql';
import { fetcher, getCurrentUserQuery } from '@affine/graphql';
-import { Trans, useI18n } from '@affine/i18n';
-import { Logo1Icon } from '@blocksuite/icons/rc';
-import { useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { useLoaderData, useSearchParams } from 'react-router-dom';
-import * as styles from './open-app.css';
-
-let lastOpened = '';
-interface OpenAppProps {
- urlToOpen?: string | null;
- channel: Channel;
-}
-
interface LoaderData {
action: 'url' | 'signin-redirect';
currentUser?: GetCurrentUserQuery['currentUser'];
}
-const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
- const t = useI18n();
- const openDownloadLink = useCallback(() => {
- const url = `https://affine.pro/download?channel=${channel}`;
- open(url, '_blank');
- }, [channel]);
- const appIcon = appIconMap[channel];
- const appName = appNames[channel];
-
- if (urlToOpen && lastOpened !== urlToOpen) {
- lastOpened = urlToOpen;
- location.href = urlToOpen;
- }
+const OpenUrl = () => {
+ const [params] = useSearchParams();
+ const urlToOpen = params.get('url');
if (!urlToOpen) {
return null;
}
- return (
-
-
-
-
-
-
-
-
-
- {t['com.affine.auth.open.affine.download-app']()}
-
-
-
-
-
- );
-};
-
-const OpenUrl = () => {
- const [params] = useSearchParams();
- const urlToOpen = params.get('url');
params.delete('url');
const urlObj = new URL(urlToOpen || '');
- const maybeScheme = appSchemes.safeParse(urlObj.protocol.replace(':', ''));
- const channel =
- schemeToChannel[maybeScheme.success ? maybeScheme.data : 'affine'];
params.forEach((v, k) => {
urlObj.searchParams.set(k, v);
});
- return ;
+ return ;
};
/**
@@ -141,7 +39,6 @@ const OpenOAuthJwt = () => {
const maybeScheme = appSchemes.safeParse(params.get('scheme'));
const scheme = maybeScheme.success ? maybeScheme.data : 'affine';
const next = params.get('next');
- const channel = schemeToChannel[scheme];
if (!currentUser || !currentUser?.token?.sessionToken) {
return null;
@@ -151,7 +48,7 @@ const OpenOAuthJwt = () => {
currentUser.token.sessionToken
}&next=${next || ''}`;
- return ;
+ return ;
};
export const Component = () => {
diff --git a/packages/frontend/core/src/desktop/pages/workspace/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/index.tsx
index de2ce48d47..40441afa09 100644
--- a/packages/frontend/core/src/desktop/pages/workspace/index.tsx
+++ b/packages/frontend/core/src/desktop/pages/workspace/index.tsx
@@ -97,10 +97,7 @@ export const Component = (): ReactElement => {
}, [listLoading, meta, workspaceNotFound, workspacesService]);
if (workspaceNotFound) {
- if (
- !BUILD_CONFIG.isElectron /* only browser has share page */ &&
- detailDocRoute
- ) {
+ if (detailDocRoute) {
return (
) => {
+ return (
+
+
+
+ );
+};
+
+const SharePageDesktopContainer = ({
+ children,
+ pageId,
+ pageTitle,
+ publishMode,
+ isTemplate,
+ templateName,
+ templateSnapshotUrl,
+}: PropsWithChildren) => {
+ useServiceOptional(DesktopStateSynchronizer);
+ return (
+
+ {/* share page does not have ViewRoot so the following does not work yet */}
+
+
+
+
+ );
+};
+
const SharePageInner = ({
workspaceId,
docId,
@@ -274,41 +354,40 @@ const SharePageInner = ({
return;
}
+ const Container = BUILD_CONFIG.isElectron
+ ? SharePageDesktopContainer
+ : SharePageWebContainer;
+
return (
-
-
-
-
-
-
-
- {publishMode === 'page' ? : null}
-
-
-
-
-
-
-
-
+
+
+
+
+ {publishMode === 'page' ? : null}
+
+
+
+
+
diff --git a/packages/frontend/core/src/modules/app-sidebar/views/app-download-button/index.tsx b/packages/frontend/core/src/modules/app-sidebar/views/app-download-button/index.tsx
index 0bbfe32345..6f39e9c178 100644
--- a/packages/frontend/core/src/modules/app-sidebar/views/app-download-button/index.tsx
+++ b/packages/frontend/core/src/modules/app-sidebar/views/app-download-button/index.tsx
@@ -6,7 +6,6 @@ import { useCallback, useState } from 'react';
import * as styles from './index.css';
-// Although it is called an input, it is actually a button.
export function AppDownloadButton({
className,
style,
diff --git a/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts b/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts
index b0f9c4a145..270e9a33d3 100644
--- a/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts
+++ b/packages/frontend/core/src/modules/app-sidebar/views/index.css.ts
@@ -9,6 +9,7 @@ export const navWrapperStyle = style({
zIndex: -1,
},
},
+ paddingBottom: 8,
selectors: {
'&[data-has-border=true]': {
borderRight: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
@@ -16,9 +17,6 @@ export const navWrapperStyle = style({
'&[data-is-floating="true"]': {
backgroundColor: cssVarV2('layer/background/primary'),
},
- '&[data-client-border="true"]': {
- paddingBottom: 8,
- },
},
});
export const hoverNavWrapperStyle = style({
diff --git a/packages/frontend/core/src/modules/app-sidebar/views/index.tsx b/packages/frontend/core/src/modules/app-sidebar/views/index.tsx
index b419f49a9b..1b91c31315 100644
--- a/packages/frontend/core/src/modules/app-sidebar/views/index.tsx
+++ b/packages/frontend/core/src/modules/app-sidebar/views/index.tsx
@@ -344,6 +344,7 @@ export * from './app-updater-button';
export * from './category-divider';
export * from './index.css';
export * from './menu-item';
+export * from './open-in-app-card';
export * from './quick-search-input';
export * from './sidebar-containers';
export * from './sidebar-header';
diff --git a/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/index.tsx b/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/index.tsx
new file mode 100644
index 0000000000..f673000682
--- /dev/null
+++ b/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/index.tsx
@@ -0,0 +1 @@
+export * from './open-in-app-card';
diff --git a/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.css.ts b/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.css.ts
new file mode 100644
index 0000000000..be0287c022
--- /dev/null
+++ b/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.css.ts
@@ -0,0 +1,69 @@
+import { cssVar } from '@toeverything/theme';
+import { cssVarV2 } from '@toeverything/theme/v2';
+import { style } from '@vanilla-extract/css';
+
+export const root = style({
+ background: cssVarV2('layer/background/primary'),
+ borderRadius: '8px',
+ border: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
+ cursor: 'default',
+ userSelect: 'none',
+});
+
+export const pane = style({
+ padding: '10px 12px',
+ display: 'flex',
+ flexDirection: 'column',
+ rowGap: 6,
+ selectors: {
+ '&:not(:last-of-type)': {
+ borderBottom: `1px solid ${cssVarV2('layer/insideBorder/border')}`,
+ },
+ },
+});
+
+export const row = style({
+ fontSize: cssVar('fontSm'),
+ fontWeight: 400,
+ display: 'flex',
+ alignItems: 'center',
+ columnGap: 10,
+ color: cssVarV2('text/secondary'),
+});
+
+export const clickableRow = style([
+ row,
+ {
+ cursor: 'pointer',
+ },
+]);
+
+export const buttonGroup = style({
+ display: 'flex',
+ gap: 4,
+});
+
+export const button = style({
+ height: 26,
+ borderRadius: 4,
+ padding: '0 8px',
+});
+
+export const primaryRow = style([
+ row,
+ {
+ color: cssVarV2('text/primary'),
+ },
+]);
+
+export const icon = style({
+ width: 20,
+ height: 20,
+ flexShrink: 0,
+ fontSize: 20,
+ selectors: {
+ [`${primaryRow} &`]: {
+ color: cssVarV2('icon/activated'),
+ },
+ },
+});
diff --git a/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.tsx b/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.tsx
new file mode 100644
index 0000000000..ba8011467d
--- /dev/null
+++ b/packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.tsx
@@ -0,0 +1,96 @@
+import { Button, Checkbox } from '@affine/component';
+import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
+import {
+ OpenInAppService,
+ OpenLinkMode,
+} from '@affine/core/modules/open-in-app';
+import { useI18n } from '@affine/i18n';
+import { track } from '@affine/track';
+import { DownloadIcon, LocalWorkspaceIcon } from '@blocksuite/icons/rc';
+import { useLiveData, useService } from '@toeverything/infra';
+import clsx from 'clsx';
+import { useCallback, useState } from 'react';
+
+import * as styles from './open-in-app-card.css';
+
+export const OpenInAppCard = ({ className }: { className?: string }) => {
+ const openInAppService = useService(OpenInAppService);
+ const show = useLiveData(openInAppService.showOpenInAppBanner$);
+ const navigateHelper = useNavigateHelper();
+ const t = useI18n();
+
+ const [remember, setRemember] = useState(false);
+
+ const onOpen = useCallback(() => {
+ navigateHelper.jumpToOpenInApp(window.location.href, true);
+ if (remember) {
+ openInAppService.setOpenLinkMode(OpenLinkMode.OPEN_IN_DESKTOP_APP);
+ }
+ }, [openInAppService, remember, navigateHelper]);
+
+ const onDismiss = useCallback(() => {
+ openInAppService.dismissBanner(
+ remember ? OpenLinkMode.OPEN_IN_WEB : undefined
+ );
+ }, [openInAppService, remember]);
+
+ const onToggleRemember = useCallback(() => {
+ setRemember(v => !v);
+ }, []);
+
+ const handleDownload = useCallback(() => {
+ track.$.navigationPanel.bottomButtons.downloadApp();
+ const url = `https://affine.pro/download?channel=stable`;
+ open(url, '_blank');
+ }, []);
+
+ if (!show) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
{t.t('com.affine.open-in-app.card.title')}
+
+
+
{/* placeholder */}
+
+
+ {t.t('com.affine.open-in-app.card.button.open')}
+
+
+ {t.t('com.affine.open-in-app.card.button.dismiss')}
+
+
+
+
+
+
+
+
+
{t.t('com.affine.open-in-app.card.remember')}
+
+
+
+
+
+
+
{t.t('com.affine.open-in-app.card.download')}
+
+
+
+ );
+};
diff --git a/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts b/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts
index 092d2c7ae8..758eb214f0 100644
--- a/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts
+++ b/packages/frontend/core/src/modules/navigation/__tests__/utils.spec.ts
@@ -1,3 +1,6 @@
+/**
+ * @vitest-environment happy-dom
+ */
import { afterEach } from 'node:test';
import { beforeEach, describe, expect, test, vi } from 'vitest';
diff --git a/packages/frontend/core/src/modules/navigation/utils.ts b/packages/frontend/core/src/modules/navigation/utils.ts
index e3bf3185af..99c5d300f9 100644
--- a/packages/frontend/core/src/modules/navigation/utils.ts
+++ b/packages/frontend/core/src/modules/navigation/utils.ts
@@ -1,3 +1,4 @@
+import { channelToScheme } from '@affine/core/utils';
import type { ReferenceParams } from '@blocksuite/affine/blocks';
import { isNil, pick, pickBy } from 'lodash-es';
import type { ParsedQuery, ParseOptions } from 'query-string';
@@ -6,7 +7,6 @@ import queryString from 'query-string';
function maybeAffineOrigin(origin: string, baseUrl: string) {
return (
origin.startsWith('file://') ||
- origin.startsWith('affine://') ||
origin.endsWith('affine.pro') || // stable/beta
origin.endsWith('affine.fail') || // canary
origin === baseUrl // localhost or self-hosted
@@ -18,6 +18,13 @@ export const resolveRouteLinkMeta = (
baseUrl = location.origin
) => {
try {
+ // if href is started with affine protocol, we need to convert it to http protocol to may URL happy
+ const affineProtocol = channelToScheme[BUILD_CONFIG.appBuildType] + '://';
+
+ if (href.startsWith(affineProtocol)) {
+ href = href.replace(affineProtocol, 'http://');
+ }
+
const url = new URL(href, baseUrl);
// check if origin is one of affine's origins
diff --git a/packages/frontend/core/src/modules/open-in-app/index.ts b/packages/frontend/core/src/modules/open-in-app/index.ts
new file mode 100644
index 0000000000..c63c1ddc98
--- /dev/null
+++ b/packages/frontend/core/src/modules/open-in-app/index.ts
@@ -0,0 +1,14 @@
+import {
+ type Framework,
+ GlobalState,
+ WorkspacesService,
+} from '@toeverything/infra';
+
+import { OpenInAppService } from './services';
+
+export * from './services';
+export * from './utils';
+
+export const configureOpenInApp = (framework: Framework) => {
+ framework.service(OpenInAppService, [GlobalState, WorkspacesService]);
+};
diff --git a/packages/frontend/core/src/modules/open-in-app/services/index.ts b/packages/frontend/core/src/modules/open-in-app/services/index.ts
new file mode 100644
index 0000000000..2064dcaa10
--- /dev/null
+++ b/packages/frontend/core/src/modules/open-in-app/services/index.ts
@@ -0,0 +1,100 @@
+import type { GlobalState, WorkspacesService } from '@toeverything/infra';
+import { LiveData, OnEvent, Service } from '@toeverything/infra';
+
+import { resolveLinkToDoc } from '../../navigation';
+import { WorkbenchLocationChanged } from '../../workbench/services/workbench';
+import { getLocalWorkspaceIds } from '../../workspace-engine/impls/local';
+
+const storageKey = 'open-link-mode';
+
+export enum OpenLinkMode {
+ ALWAYS_ASK = 'always-ask', // default
+ OPEN_IN_WEB = 'open-in-web',
+ OPEN_IN_DESKTOP_APP = 'open-in-desktop-app',
+}
+
+@OnEvent(WorkbenchLocationChanged, e => e.onNavigation)
+export class OpenInAppService extends Service {
+ private initialized = false;
+
+ private initialUrl: string | undefined;
+
+ readonly showOpenInAppBanner$ = new LiveData(false);
+ readonly showOpenInAppPage$ = new LiveData(undefined);
+
+ constructor(
+ public readonly globalState: GlobalState,
+ public readonly workspacesService: WorkspacesService
+ ) {
+ super();
+ }
+
+ onNavigation() {
+ // check doc id instead?
+ if (window.location.href === this.initialUrl) {
+ return;
+ }
+ this.showOpenInAppBanner$.next(false);
+ }
+
+ /**
+ * Given the initial URL, check if we need to redirect to the desktop app.
+ */
+ bootstrap() {
+ if (this.initialized || !window) {
+ return;
+ }
+
+ this.initialized = true;
+ this.initialUrl = window.location.href;
+
+ const maybeDocLink = resolveLinkToDoc(this.initialUrl);
+ let shouldOpenInApp = false;
+
+ const localWorkspaceIds = getLocalWorkspaceIds();
+
+ if (maybeDocLink && !localWorkspaceIds.includes(maybeDocLink.workspaceId)) {
+ switch (this.getOpenLinkMode()) {
+ case OpenLinkMode.OPEN_IN_DESKTOP_APP:
+ shouldOpenInApp = true;
+ break;
+ case OpenLinkMode.ALWAYS_ASK:
+ this.showOpenInAppBanner$.next(true);
+ break;
+ default:
+ break;
+ }
+ }
+ this.showOpenInAppPage$.next(shouldOpenInApp);
+ }
+
+ showOpenInAppPage() {
+ this.showOpenInAppPage$.next(true);
+ }
+
+ hideOpenInAppPage() {
+ this.showOpenInAppPage$.next(false);
+ }
+
+ getOpenLinkMode() {
+ return (
+ this.globalState.get(storageKey) ?? OpenLinkMode.ALWAYS_ASK
+ );
+ }
+
+ openLinkMode$ = LiveData.from(
+ this.globalState.watch(storageKey),
+ this.getOpenLinkMode()
+ ).map(v => v ?? OpenLinkMode.ALWAYS_ASK);
+
+ setOpenLinkMode(mode: OpenLinkMode) {
+ this.globalState.set(storageKey, mode);
+ }
+
+ dismissBanner(rememberMode: OpenLinkMode | undefined) {
+ if (rememberMode) {
+ this.globalState.set(storageKey, rememberMode);
+ }
+ this.showOpenInAppBanner$.next(false);
+ }
+}
diff --git a/packages/frontend/core/src/modules/open-in-app/utils.ts b/packages/frontend/core/src/modules/open-in-app/utils.ts
index f0f55df152..0b16fbc6cd 100644
--- a/packages/frontend/core/src/modules/open-in-app/utils.ts
+++ b/packages/frontend/core/src/modules/open-in-app/utils.ts
@@ -1,27 +1,25 @@
+import { channelToScheme } from '@affine/core/utils';
import { DebugLogger } from '@affine/debug';
-import { channelToScheme } from './constant';
-
const logger = new DebugLogger('open-in-app');
// return an AFFiNE app's url to be opened in desktop app
export const getOpenUrlInDesktopAppLink = (
url: string,
- newTab = false,
+ newTab = true,
scheme = channelToScheme[BUILD_CONFIG.appBuildType]
) => {
- if (!scheme) {
- return null;
- }
-
- const urlObject = new URL(url);
- const params = urlObject.searchParams;
-
- if (newTab) {
- params.set('new-tab', '1');
- }
-
try {
+ if (!scheme) {
+ return null;
+ }
+
+ const urlObject = new URL(url, location.origin);
+ const params = urlObject.searchParams;
+
+ if (newTab) {
+ params.set('new-tab', '1');
+ }
return new URL(
`${scheme}://${urlObject.host}${urlObject.pathname}?${params.toString()}#${urlObject.hash}`
).toString();
diff --git a/packages/frontend/core/src/modules/open-in-app/views/open-in-app-guard.tsx b/packages/frontend/core/src/modules/open-in-app/views/open-in-app-guard.tsx
new file mode 100644
index 0000000000..fade4f1230
--- /dev/null
+++ b/packages/frontend/core/src/modules/open-in-app/views/open-in-app-guard.tsx
@@ -0,0 +1,44 @@
+import { assertExists } from '@blocksuite/affine/global/utils';
+import { useLiveData, useService } from '@toeverything/infra';
+import { useCallback, useEffect } from 'react';
+
+import { OpenInAppService } from '../services';
+import { OpenInAppPage } from './open-in-app-page';
+
+/**
+ * Web only guard to open the URL in desktop app for different conditions
+ */
+export const WebOpenInAppGuard = ({
+ children,
+}: {
+ children: React.ReactNode;
+}) => {
+ assertExists(
+ BUILD_CONFIG.isWeb,
+ 'WebOpenInAppGuard should only be used in web'
+ );
+ const service = useService(OpenInAppService);
+ const shouldOpenInApp = useLiveData(service.showOpenInAppPage$);
+
+ useEffect(() => {
+ service?.bootstrap();
+ }, [service]);
+
+ const onOpenHere = useCallback(
+ (e: React.MouseEvent) => {
+ e.preventDefault();
+ service.hideOpenInAppPage();
+ },
+ [service]
+ );
+
+ if (shouldOpenInApp === undefined) {
+ return null;
+ }
+
+ return shouldOpenInApp ? (
+
+ ) : (
+ children
+ );
+};
diff --git a/packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.css.ts b/packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.css.ts
new file mode 100644
index 0000000000..6751571559
--- /dev/null
+++ b/packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.css.ts
@@ -0,0 +1,64 @@
+import { cssVar } from '@toeverything/theme';
+import { style } from '@vanilla-extract/css';
+export const root = style({
+ height: '100vh',
+ width: '100vw',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ fontSize: cssVar('fontBase'),
+ position: 'relative',
+});
+export const affineLogo = style({
+ color: 'inherit',
+});
+export const topNav = style({
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: '16px 120px',
+});
+export const topNavLinks = style({
+ display: 'flex',
+ columnGap: 4,
+});
+export const topNavLink = style({
+ color: cssVar('textPrimaryColor'),
+ fontSize: cssVar('fontSm'),
+ fontWeight: 500,
+ textDecoration: 'none',
+ padding: '4px 18px',
+});
+
+export const promptLinks = style({
+ display: 'flex',
+ columnGap: 16,
+});
+export const promptLink = style({
+ color: cssVar('linkColor'),
+ fontWeight: 500,
+ textDecoration: 'none',
+ fontSize: cssVar('fontSm'),
+});
+export const centerContent = style({
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ marginTop: 40,
+});
+export const prompt = style({
+ marginTop: 20,
+ marginBottom: 12,
+});
+export const editSettingsLink = style({
+ fontWeight: 500,
+ textDecoration: 'none',
+ color: cssVar('linkColor'),
+ fontSize: cssVar('fontSm'),
+ position: 'absolute',
+ bottom: 48,
+});
diff --git a/packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.tsx b/packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.tsx
new file mode 100644
index 0000000000..1a972e1fed
--- /dev/null
+++ b/packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.tsx
@@ -0,0 +1,160 @@
+import { Button } from '@affine/component/ui/button';
+import { openSettingModalAtom } from '@affine/core/components/atoms';
+import { resolveLinkToDoc } from '@affine/core/modules/navigation';
+import { appIconMap, appNames } from '@affine/core/utils';
+import { Trans, useI18n } from '@affine/i18n';
+import { Logo1Icon } from '@blocksuite/icons/rc';
+import { useSetAtom } from 'jotai';
+import type { MouseEvent } from 'react';
+import { useCallback } from 'react';
+
+import { getOpenUrlInDesktopAppLink } from '../utils';
+import * as styles from './open-in-app-page.css';
+
+let lastOpened = '';
+
+interface OpenAppProps {
+ urlToOpen?: string | null;
+ openHereClicked?: (e: MouseEvent) => void;
+}
+
+export const OpenInAppPage = ({ urlToOpen, openHereClicked }: OpenAppProps) => {
+ // default to open the current page in desktop app
+ urlToOpen ??= getOpenUrlInDesktopAppLink(window.location.href, true);
+ const t = useI18n();
+ const channel = BUILD_CONFIG.appBuildType;
+ const openDownloadLink = useCallback(() => {
+ const url =
+ 'https://affine.pro/download' +
+ (channel !== 'stable' ? '/beta-canary' : '');
+ open(url, '_blank');
+ }, [channel]);
+
+ const appIcon = appIconMap[channel];
+ const appName = appNames[channel];
+
+ const maybeDocLink = urlToOpen ? resolveLinkToDoc(urlToOpen) : null;
+
+ const goToDocPage = useCallback(
+ (e: MouseEvent) => {
+ if (!maybeDocLink) {
+ return;
+ }
+ openHereClicked?.(e);
+ },
+ [maybeDocLink, openHereClicked]
+ );
+
+ const setSettingModalAtom = useSetAtom(openSettingModalAtom);
+
+ const goToAppearanceSetting = useCallback(
+ (e: MouseEvent) => {
+ openHereClicked?.(e);
+ setSettingModalAtom({
+ open: true,
+ activeTab: 'appearance',
+ });
+ },
+ [openHereClicked, setSettingModalAtom]
+ );
+
+ if (urlToOpen && lastOpened !== urlToOpen) {
+ lastOpened = urlToOpen;
+ location.href = urlToOpen;
+ }
+
+ if (!urlToOpen) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/frontend/core/src/modules/open-in-app/constant.ts b/packages/frontend/core/src/utils/channel.ts
similarity index 100%
rename from packages/frontend/core/src/modules/open-in-app/constant.ts
rename to packages/frontend/core/src/utils/channel.ts
diff --git a/packages/frontend/core/src/utils/index.ts b/packages/frontend/core/src/utils/index.ts
index 771bab3cc5..999b16109b 100644
--- a/packages/frontend/core/src/utils/index.ts
+++ b/packages/frontend/core/src/utils/index.ts
@@ -1,3 +1,4 @@
+export * from './channel';
export * from './create-emotion-cache';
export * from './event';
export * from './extract-emoji-icon';
diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json
index ed7d79e4a9..ecccc087df 100644
--- a/packages/frontend/i18n/src/i18n-completenesses.json
+++ b/packages/frontend/i18n/src/i18n-completenesses.json
@@ -7,16 +7,16 @@
"es-AR": 15,
"es-CL": 17,
"es": 15,
- "fr": 75,
+ "fr": 74,
"hi": 2,
"it": 1,
- "ja": 100,
- "ko": 89,
+ "ja": 99,
+ "ko": 88,
"pl": 0,
"pt-BR": 96,
"ru": 82,
"sv-SE": 5,
"ur": 3,
- "zh-Hans": 99,
- "zh-Hant": 97
+ "zh-Hans": 98,
+ "zh-Hant": 96
}
\ No newline at end of file
diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json
index 5578d0a19d..d5fbc0a0ef 100644
--- a/packages/frontend/i18n/src/resources/en.json
+++ b/packages/frontend/i18n/src/resources/en.json
@@ -229,6 +229,8 @@
"com.affine.auth.open.affine.download-app": "Download app",
"com.affine.auth.open.affine.prompt": "Opening <1>AFFiNE1> app now",
"com.affine.auth.open.affine.try-again": "Try again",
+ "com.affine.auth.open.affine.doc.open-here": "Open here instead",
+ "com.affine.auth.open.affine.doc.edit-settings": "Edit settings",
"com.affine.auth.page.sent.email.subtitle": "Please set a password of {{min}}-{{max}} characters with both letters and numbers to continue signing up with ",
"com.affine.auth.page.sent.email.title": "Welcome to AFFiNE Cloud, you are almost there!",
"com.affine.auth.password": "Password",
@@ -1019,6 +1021,18 @@
"com.affine.settings.appearance.language-description": "Select the language for the interface.",
"com.affine.settings.appearance.start-week-description": "By default, the week starts on Sunday.",
"com.affine.settings.appearance.window-frame-description": "Customise appearance of Windows Client.",
+ "com.affine.setting.appearance.links": "Links",
+ "com.affine.setting.appearance.open-in-app": "Open AFFiNE links",
+ "com.affine.setting.appearance.open-in-app.hint": "You can choose to open the link in the desktop app or directly in the browser.",
+ "com.affine.setting.appearance.open-in-app.always-ask": "Ask me each time",
+ "com.affine.setting.appearance.open-in-app.open-in-desktop-app": "Open links in desktop app",
+ "com.affine.setting.appearance.open-in-app.open-in-web": "Open links in browser",
+ "com.affine.setting.appearance.open-in-app.title": "Open AFFiNE links",
+ "com.affine.open-in-app.card.title": "Open this page in app?",
+ "com.affine.open-in-app.card.button.open": "Open in app",
+ "com.affine.open-in-app.card.button.dismiss": "Dismiss",
+ "com.affine.open-in-app.card.remember": "Remember my choice",
+ "com.affine.open-in-app.card.download": "Download desktop app",
"com.affine.settings.auto-check-description": "If enabled, it will automatically check for new versions at regular intervals.",
"com.affine.settings.auto-download-description": "If enabled, new versions will be automatically downloaded to the current device.",
"com.affine.settings.editorSettings": "Editor",