From 0f8b273134c42baee2c0ed92f8cb9b87e7490ef1 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Thu, 31 Oct 2024 06:16:32 +0000 Subject: [PATCH] feat(core): open app in electron app entry (#8637) fix PD-208 fix PD-210 fix PD-209 fix AF-1495 --- .../apps/electron/src/main/deep-link.ts | 19 +-- .../src/main/windows-manager/main-window.ts | 12 -- .../src/main/windows-manager/tab-views.ts | 28 +++ packages/frontend/apps/web/src/app.tsx | 15 +- .../component/src/ui/checkbox/checkbox.tsx | 4 +- .../general-setting/about/index.tsx | 5 +- .../general-setting/appearance/index.tsx | 12 ++ .../general-setting/appearance/links.css.ts | 18 ++ .../general-setting/appearance/links.tsx | 55 ++++++ .../affine/subscription-landing/index.tsx | 6 +- .../block-suite-header/menu/index.tsx | 14 +- .../components/hooks/use-navigate-helper.ts | 19 ++- .../src/components/root-app-sidebar/index.tsx | 4 +- .../core/src/desktop/pages/open-app.css.ts | 15 +- .../core/src/desktop/pages/open-app.tsx | 117 +------------ .../src/desktop/pages/workspace/index.tsx | 5 +- .../pages/workspace/share/share-page.css.ts | 7 + .../pages/workspace/share/share-page.tsx | 141 +++++++++++---- .../views/app-download-button/index.tsx | 1 - .../modules/app-sidebar/views/index.css.ts | 4 +- .../src/modules/app-sidebar/views/index.tsx | 1 + .../views/open-in-app-card/index.tsx | 1 + .../open-in-app-card/open-in-app-card.css.ts | 69 ++++++++ .../open-in-app-card/open-in-app-card.tsx | 96 +++++++++++ .../navigation/__tests__/utils.spec.ts | 3 + .../core/src/modules/navigation/utils.ts | 9 +- .../core/src/modules/open-in-app/index.ts | 14 ++ .../src/modules/open-in-app/services/index.ts | 100 +++++++++++ .../core/src/modules/open-in-app/utils.ts | 24 ++- .../open-in-app/views/open-in-app-guard.tsx | 44 +++++ .../open-in-app/views/open-in-app-page.css.ts | 64 +++++++ .../open-in-app/views/open-in-app-page.tsx | 160 ++++++++++++++++++ .../constant.ts => utils/channel.ts} | 0 packages/frontend/core/src/utils/index.ts | 1 + .../i18n/src/i18n-completenesses.json | 10 +- packages/frontend/i18n/src/resources/en.json | 14 ++ 36 files changed, 886 insertions(+), 225 deletions(-) create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/links.css.ts create mode 100644 packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/links.tsx create mode 100644 packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/index.tsx create mode 100644 packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.css.ts create mode 100644 packages/frontend/core/src/modules/app-sidebar/views/open-in-app-card/open-in-app-card.tsx create mode 100644 packages/frontend/core/src/modules/open-in-app/index.ts create mode 100644 packages/frontend/core/src/modules/open-in-app/services/index.ts create mode 100644 packages/frontend/core/src/modules/open-in-app/views/open-in-app-guard.tsx create mode 100644 packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.css.ts create mode 100644 packages/frontend/core/src/modules/open-in-app/views/open-in-app-page.tsx rename packages/frontend/core/src/{modules/open-in-app/constant.ts => utils/channel.ts} (100%) diff --git a/packages/frontend/apps/electron/src/main/deep-link.ts b/packages/frontend/apps/electron/src/main/deep-link.ts index a38ac591a820..75dab53d8a12 100644 --- a/packages/frontend/apps/electron/src/main/deep-link.ts +++ b/packages/frontend/apps/electron/src/main/deep-link.ts @@ -6,10 +6,10 @@ import { buildType, isDev } from './config'; import { logger } from './logger'; import { uiSubjects } from './ui'; import { - addTab, + addTabWithUrl, getMainWindow, + loadUrlInActiveTab, openUrlInHiddenWindow, - openUrlInMainWindow, showMainWindow, } from './windows-manager'; @@ -88,22 +88,11 @@ async function handleAffineUrl(url: string) { ) { // @todo(@forehalo): refactor router utilities // basename of /workspace/xxx/yyy is /workspace/xxx - const basename = urlObj.pathname.split('/').slice(0, 3).join('/'); - const pathname = '/' + urlObj.pathname.split('/').slice(3).join('/'); - - await addTab({ - basename, - show: true, - view: { - path: { - pathname: pathname, - }, - }, - }); + await addTabWithUrl(url); } else { const hiddenWindow = urlObj.searchParams.get('hidden') ? await openUrlInHiddenWindow(urlObj) - : await openUrlInMainWindow(urlObj); + : await loadUrlInActiveTab(url); const main = await getMainWindow(); if (main && hiddenWindow) { diff --git a/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts b/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts index 19cc241ff148..09fbf91848c4 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/main-window.ts @@ -273,15 +273,3 @@ export async function openUrlInHiddenWindow(urlObj: URL) { }); return win; } - -// TODO(@pengx17): somehow the page won't load the url passed, help needed -export async function openUrlInMainWindow(urlObj: URL) { - const url = transformToAppUrl(urlObj); - logger.info('loading page at', url); - const mainWindow = await getMainWindow(); - if (mainWindow) { - await mainWindow.loadURL(url); - } - - return null; -} diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts index 040510f7a5ff..cfb94869c2d7 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts @@ -932,7 +932,35 @@ export const isActiveTab = (wc: WebContents) => { WebContentViewsManager.instance.activeWorkbenchView?.webContents.id ); }; + +// parse the full pathname to basename and pathname +// eg: /workspace/xxx/yyy => { basename: '/workspace/xxx', pathname: '/yyy' } +export const parseFullPathname = (url: string) => { + const urlObj = new URL(url); + const basename = urlObj.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/'; + return { + basename, + pathname: urlObj.pathname.slice(basename.length), + search: urlObj.search, + hash: urlObj.hash, + }; +}; + export const addTab = WebContentViewsManager.instance.addTab; +export const addTabWithUrl = (url: string) => { + const { basename, pathname, search, hash } = parseFullPathname(url); + return addTab({ + basename, + view: { + path: { pathname, search, hash }, + }, + }); +}; + +export const loadUrlInActiveTab = async (_url: string) => { + // todo: implement + throw new Error('loadUrlInActiveTab not implemented'); +}; export const showTab = WebContentViewsManager.instance.showTab; export const closeTab = WebContentViewsManager.instance.closeTab; export const undoCloseTab = WebContentViewsManager.instance.undoCloseTab; diff --git a/packages/frontend/apps/web/src/app.tsx b/packages/frontend/apps/web/src/app.tsx index 0ad58c379de7..506d2925c1bf 100644 --- a/packages/frontend/apps/web/src/app.tsx +++ b/packages/frontend/apps/web/src/app.tsx @@ -5,6 +5,8 @@ import { Telemetry } from '@affine/core/components/telemetry'; import { router } from '@affine/core/desktop/router'; import { configureCommonModules } from '@affine/core/modules'; import { I18nProvider } from '@affine/core/modules/i18n'; +import { configureOpenInApp } from '@affine/core/modules/open-in-app'; +import { WebOpenInAppGuard } from '@affine/core/modules/open-in-app/views/open-in-app-guard'; import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage'; import { CustomThemeModifier } from '@affine/core/modules/theme-editor'; import { PopupWindowProvider } from '@affine/core/modules/url'; @@ -38,6 +40,7 @@ configureLocalStorageStateStorageImpls(framework); configureBrowserWorkspaceFlavours(framework); configureIndexedDBWorkspaceEngineStorageProvider(framework); configureIndexedDBUserspaceStorageProvider(framework); +configureOpenInApp(framework); framework.impl(PopupWindowProvider, { open: (target: string) => { const targetUrl = new URL(target); @@ -75,11 +78,13 @@ export function App() { - } - router={router} - future={future} - /> + + } + router={router} + future={future} + /> + diff --git a/packages/frontend/component/src/ui/checkbox/checkbox.tsx b/packages/frontend/component/src/ui/checkbox/checkbox.tsx index 0eb45202bafa..ea2fba19e480 100644 --- a/packages/frontend/component/src/ui/checkbox/checkbox.tsx +++ b/packages/frontend/component/src/ui/checkbox/checkbox.tsx @@ -11,7 +11,7 @@ export type CheckboxProps = Omit< 'onChange' > & { checked: boolean; - onChange: ( + onChange?: ( event: React.ChangeEvent, checked: boolean ) => void; @@ -41,7 +41,7 @@ export const Checkbox = ({ const handleChange = useCallback( (event: React.ChangeEvent) => { const newChecked = event.target.checked; - onChange(event, newChecked); + onChange?.(event, newChecked); const inputElement = inputRef.current; if (newChecked && inputElement && animation) { playCheckAnimation(inputElement.parentElement as Element).catch( diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx index 2ffcee296501..c9a6bbdf4d37 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/about/index.tsx @@ -5,11 +5,8 @@ import { SettingWrapper, } from '@affine/component/setting-components'; import { useAppUpdater } from '@affine/core/components/hooks/use-app-updater'; -import { - appIconMap, - appNames, -} from '@affine/core/modules/open-in-app/constant'; import { UrlService } from '@affine/core/modules/url'; +import { appIconMap, appNames } from '@affine/core/utils'; import { useI18n } from '@affine/i18n'; import { mixpanel } from '@affine/track'; import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons/rc'; diff --git a/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx index 59c3b9ad1749..bf195381775a 100644 --- a/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx +++ b/packages/frontend/core/src/components/affine/setting-modal/general-setting/appearance/index.tsx @@ -16,6 +16,7 @@ import { useCallback, useMemo } from 'react'; import { useAppSettingHelper } from '../../../../../components/hooks/affine/use-app-setting-helper'; import { LanguageMenu } from '../../../language-menu'; +import { OpenInAppLinksMenu } from './links'; import { settingWrapper } from './style.css'; import { ThemeEditorSetting } from './theme-editor-setting'; @@ -106,6 +107,17 @@ export const AppearanceSettings = () => { {enableThemeEditor ? : null} + {BUILD_CONFIG.isWeb ? ( + + + + + + ) : null} {BUILD_CONFIG.isElectron ? ( { + 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 1129be8301e0..4138a6388f5f 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 d1b04b6f5657..abe716f5e6f6 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 3d69a67c7a3f..a6a5927be7e4 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 36e6b23202f0..0538ba27223f 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 d46a5cbe1441..67515715594e 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 3338925a0c98..e10deb467832 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 ( -
-
- - - - - - - -
- -
- {appName} - -
- - Open {appName} app now - -
- - - {t['com.affine.auth.open.affine.try-again']()} - -
-
- ); -}; - -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 de2ce48d4773..40441afa099f 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 ( + +
+
+ + {children} + +
+ +
+
+ ); +}; + +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 */} + + +
+ +
+ + {children} +
+
+
+ ); +}; + 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 0bbfe3234517..6f39e9c1788e 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 b0f9c4a14508..270e9a33d399 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 b419f49a9bb8..1b91c3131502 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 000000000000..f6730006827c --- /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 000000000000..be0287c022b2 --- /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 000000000000..ba8011467d8a --- /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.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 092d2c7ae801..758eb214f0c3 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 e3bf3185af16..99c5d300f92f 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 000000000000..c63c1ddc98f1 --- /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 000000000000..2064dcaa103a --- /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 f0f55df1521d..0b16fbc6cdcd 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; + try { + if (!scheme) { + return null; + } - if (newTab) { - params.set('new-tab', '1'); - } + const urlObject = new URL(url, location.origin); + const params = urlObject.searchParams; - try { + 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 000000000000..fade4f123076 --- /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 000000000000..67515715594e --- /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 000000000000..1a972e1fed34 --- /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 ( +
+
+ + + + + + + +
+ +
+ {appName} + +
+ + Open {appName} app now + +
+ + +
+ + {maybeDocLink ? ( + + {t['com.affine.auth.open.affine.doc.edit-settings']()} + + ) : null} +
+ ); +}; 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 771bab3cc5eb..999b16109b8a 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 ed7d79e4a993..ecccc087dfd0 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 5578d0a19d0a..d5fbc0a0ef90 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>AFFiNE 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",