From 5acf1b53090ddc98fde7c57620f3a86c26f02488 Mon Sep 17 00:00:00 2001 From: pengx17 Date: Wed, 21 Aug 2024 13:17:35 +0000 Subject: [PATCH] feat: init mobile entry (#7905) --- .../core/src/layouts/workspace-layout.tsx | 2 +- .../src/modules/workbench/view/view-root.tsx | 33 ++---- .../modules/workbench/view/workbench-root.tsx | 31 ++++- .../src/pages/workspace/workbench-root.tsx | 26 ++++ packages/frontend/core/src/router.tsx | 2 +- packages/frontend/mobile/package.json | 28 +++++ packages/frontend/mobile/project.json | 69 +++++++++++ packages/frontend/mobile/src/app.tsx | 91 ++++++++++++++ packages/frontend/mobile/src/index.tsx | 76 ++++++++++++ packages/frontend/mobile/src/pages/404.tsx | 3 + packages/frontend/mobile/src/pages/auth.tsx | 3 + packages/frontend/mobile/src/pages/index.tsx | 8 ++ .../frontend/mobile/src/pages/sign-in.tsx | 7 ++ .../mobile/src/pages/workspace/all.tsx | 3 + .../src/pages/workspace/collection/detail.tsx | 3 + .../src/pages/workspace/collection/index.tsx | 3 + .../mobile/src/pages/workspace/detail.tsx | 3 + .../mobile/src/pages/workspace/index.tsx | 112 ++++++++++++++++++ .../mobile/src/pages/workspace/layout.tsx | 92 ++++++++++++++ .../mobile/src/pages/workspace/tag/detail.tsx | 3 + .../mobile/src/pages/workspace/tag/index.tsx | 3 + .../mobile/src/pages/workspace/trash.tsx | 3 + .../frontend/mobile/src/polyfill/dispose.ts | 2 + .../mobile/src/polyfill/intl-segmenter.ts | 11 ++ .../src/polyfill/promise-with-resolvers.ts | 1 + .../src/polyfill/request-idle-callback.ts | 19 +++ .../mobile/src/polyfill/set-immediate.ts | 1 + packages/frontend/mobile/src/router.tsx | 95 +++++++++++++++ packages/frontend/mobile/tsconfig.json | 12 ++ tools/cli/src/bin/dev.ts | 3 + tools/cli/src/config/cwd.cjs | 2 + tools/cli/src/config/index.ts | 2 +- yarn.lock | 20 ++++ 33 files changed, 744 insertions(+), 28 deletions(-) create mode 100644 packages/frontend/core/src/pages/workspace/workbench-root.tsx create mode 100644 packages/frontend/mobile/package.json create mode 100644 packages/frontend/mobile/project.json create mode 100644 packages/frontend/mobile/src/app.tsx create mode 100644 packages/frontend/mobile/src/index.tsx create mode 100644 packages/frontend/mobile/src/pages/404.tsx create mode 100644 packages/frontend/mobile/src/pages/auth.tsx create mode 100644 packages/frontend/mobile/src/pages/index.tsx create mode 100644 packages/frontend/mobile/src/pages/sign-in.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/all.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/collection/detail.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/collection/index.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/detail.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/index.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/layout.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/tag/detail.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/tag/index.tsx create mode 100644 packages/frontend/mobile/src/pages/workspace/trash.tsx create mode 100644 packages/frontend/mobile/src/polyfill/dispose.ts create mode 100644 packages/frontend/mobile/src/polyfill/intl-segmenter.ts create mode 100644 packages/frontend/mobile/src/polyfill/promise-with-resolvers.ts create mode 100644 packages/frontend/mobile/src/polyfill/request-idle-callback.ts create mode 100644 packages/frontend/mobile/src/polyfill/set-immediate.ts create mode 100644 packages/frontend/mobile/src/router.tsx create mode 100644 packages/frontend/mobile/tsconfig.json diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index b19d181a73..08b8064c70 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -73,7 +73,7 @@ export const WorkspaceLayout = function WorkspaceLayout({ ); }; -const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => { +export const WorkspaceLayoutProviders = ({ children }: PropsWithChildren) => { const t = useI18n(); const pushGlobalLoadingEvent = useSetAtom(pushGlobalLoadingEventAtom); const resolveGlobalLoadingEvent = useSetAtom(resolveGlobalLoadingEventAtom); diff --git a/packages/frontend/core/src/modules/workbench/view/view-root.tsx b/packages/frontend/core/src/modules/workbench/view/view-root.tsx index 55c1e2db96..9a29528683 100644 --- a/packages/frontend/core/src/modules/workbench/view/view-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/view-root.tsx @@ -1,5 +1,6 @@ import { FrameworkScope, useLiveData } from '@toeverything/infra'; -import { lazy as reactLazy, useLayoutEffect, useMemo } from 'react'; +import { useLayoutEffect, useMemo } from 'react'; +import type { RouteObject } from 'react-router-dom'; import { createMemoryRouter, RouterProvider, @@ -7,30 +8,16 @@ import { UNSAFE_RouteContext, } from 'react-router-dom'; -import { viewRoutes } from '../../../router'; import type { View } from '../entities/view'; -import { RouteContainer } from './route-container'; -const warpedRoutes = viewRoutes.map(({ path, lazy }) => { - const Component = reactLazy(() => - lazy().then(m => ({ - default: m.Component as React.ComponentType, - })) - ); - const route = { - Component, - }; - - return { - path, - Component: () => { - return ; - }, - }; -}); - -export const ViewRoot = ({ view }: { view: View }) => { - const viewRouter = useMemo(() => createMemoryRouter(warpedRoutes), []); +export const ViewRoot = ({ + view, + routes, +}: { + view: View; + routes: RouteObject[]; +}) => { + const viewRouter = useMemo(() => createMemoryRouter(routes), [routes]); const location = useLiveData(view.location$); diff --git a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx index 8b50d7b16a..9ce7b81717 100644 --- a/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx +++ b/packages/frontend/core/src/modules/workbench/view/workbench-root.tsx @@ -1,5 +1,6 @@ import { ResizePanel } from '@affine/component/resize-panel'; import { rightSidebarWidthAtom } from '@affine/core/atoms'; +import { viewRoutes } from '@affine/core/router'; import { appSettingAtom, FrameworkScope, @@ -7,13 +8,21 @@ import { useService, } from '@toeverything/infra'; import { useAtom, useAtomValue } from 'jotai'; -import { memo, useCallback, useEffect, useRef, useState } from 'react'; +import { + lazy as reactLazy, + memo, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; import { useLocation } from 'react-router-dom'; import type { View } from '../entities/view'; import { WorkbenchService } from '../services/workbench'; import { useBindWorkbenchToBrowserRouter } from './browser-adapter'; import { useBindWorkbenchToDesktopRouter } from './desktop-adapter'; +import { RouteContainer } from './route-container'; import { SidebarContainer } from './sidebar/sidebar-container'; import { SplitView } from './split-view/split-view'; import { ViewIslandRegistryProvider } from './view-islands'; @@ -24,6 +33,24 @@ const useAdapter = environment.isDesktop ? useBindWorkbenchToDesktopRouter : useBindWorkbenchToBrowserRouter; +const warpedRoutes = viewRoutes.map(({ path, lazy }) => { + const Component = reactLazy(() => + lazy().then(m => ({ + default: m.Component as React.ComponentType, + })) + ); + const route = { + Component, + }; + + return { + path, + Component: () => { + return ; + }, + }; +}); + export const WorkbenchRoot = memo(() => { const workbench = useService(WorkbenchService).workbench; @@ -93,7 +120,7 @@ const WorkbenchView = ({ view, index }: { view: View; index: number }) => { return (
- +
); }; diff --git a/packages/frontend/core/src/pages/workspace/workbench-root.tsx b/packages/frontend/core/src/pages/workspace/workbench-root.tsx new file mode 100644 index 0000000000..e12e9b2d5e --- /dev/null +++ b/packages/frontend/core/src/pages/workspace/workbench-root.tsx @@ -0,0 +1,26 @@ +import { WorkbenchService } from '@affine/core/modules/workbench'; +import { useBindWorkbenchToBrowserRouter } from '@affine/core/modules/workbench/view/browser-adapter'; +import { ViewRoot } from '@affine/core/modules/workbench/view/view-root'; +import { useLiveData, useService } from '@toeverything/infra'; +import { useEffect } from 'react'; +import { type RouteObject, useLocation } from 'react-router-dom'; + +export const MobileWorkbenchRoot = ({ routes }: { routes: RouteObject[] }) => { + const workbench = useService(WorkbenchService).workbench; + + // for debugging + (window as any).workbench = workbench; + + const views = useLiveData(workbench.views$); + + const location = useLocation(); + const basename = location.pathname.match(/\/workspace\/[^/]+/g)?.[0] ?? '/'; + + useBindWorkbenchToBrowserRouter(workbench, basename); + + useEffect(() => { + workbench.updateBasename(basename); + }, [basename, workbench]); + + return ; +}; diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx index e43c36cf3a..ec8b42ff1e 100644 --- a/packages/frontend/core/src/router.tsx +++ b/packages/frontend/core/src/router.tsx @@ -11,7 +11,7 @@ import { export const NavigateContext = createContext(null); -function RootRouter() { +export function RootRouter() { const navigate = useNavigate(); const [ready, setReady] = useState(false); useEffect(() => { diff --git a/packages/frontend/mobile/package.json b/packages/frontend/mobile/package.json new file mode 100644 index 0000000000..dfabc27b90 --- /dev/null +++ b/packages/frontend/mobile/package.json @@ -0,0 +1,28 @@ +{ + "name": "@affine/mobile", + "version": "0.16.0", + "description": "AFFiNE Desktop Web application", + "private": true, + "browser": "src/index.tsx", + "scripts": { + "build": "cross-env DISTRIBUTION=mobile yarn workspace @affine/cli build", + "dev": "yarn workspace @affine/cli dev" + }, + "dependencies": { + "@affine/component": "workspace:*", + "@affine/core": "workspace:*", + "@affine/env": "workspace:*", + "@sentry/react": "^8.0.0", + "core-js": "^3.36.1", + "intl-segmenter-polyfill-rs": "^0.1.7", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@affine/cli": "workspace:*", + "@types/react": "^18.2.75", + "@types/react-dom": "^18.2.24", + "cross-env": "^7.0.3", + "typescript": "^5.4.5" + } +} diff --git a/packages/frontend/mobile/project.json b/packages/frontend/mobile/project.json new file mode 100644 index 0000000000..ca8ed34e3b --- /dev/null +++ b/packages/frontend/mobile/project.json @@ -0,0 +1,69 @@ +{ + "name": "@affine/mobile", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "targets": { + "build": { + "executor": "nx:run-script", + "dependsOn": [ + { + "projects": ["tag:infra"], + "target": "build", + "params": "ignore" + }, + "^build" + ], + "inputs": [ + "{projectRoot}/**/*", + "{workspaceRoot}/tools/**/*", + "{workspaceRoot}/packages/frontend/core/**/*", + "{workspaceRoot}/packages/**/*", + { + "env": "BUILD_TYPE" + }, + { + "env": "BUILD_TYPE_OVERRIDE" + }, + { + "env": "PERFSEE_TOKEN" + }, + { + "env": "SENTRY_ORG" + }, + { + "env": "SENTRY_PROJECT" + }, + { + "env": "SENTRY_AUTH_TOKEN" + }, + { + "env": "SENTRY_DSN" + }, + { + "env": "DISTRIBUTION" + }, + { + "env": "COVERAGE" + }, + { + "env": "DISABLE_DEV_OVERLAY" + }, + { + "env": "CAPTCHA_SITE_KEY" + }, + { + "env": "R2_ACCOUNT_ID" + }, + { + "env": "R2_ACCESS_KEY_ID" + }, + { + "env": "R2_SECRET_ACCESS_KEY" + } + ], + "options": { + "script": "build" + }, + "outputs": ["{projectRoot}/dist"] + } + } +} diff --git a/packages/frontend/mobile/src/app.tsx b/packages/frontend/mobile/src/app.tsx new file mode 100644 index 0000000000..8e46841299 --- /dev/null +++ b/packages/frontend/mobile/src/app.tsx @@ -0,0 +1,91 @@ +import '@affine/component/theme/global.css'; +import '@affine/component/theme/theme.css'; + +import { NotificationCenter } from '@affine/component'; +import { AffineContext } from '@affine/component/context'; +import { AppFallback } from '@affine/core/components/affine/app-container'; +import { configureCommonModules } from '@affine/core/modules'; +import { configureLocalStorageStateStorageImpls } from '@affine/core/modules/storage'; +import { configureBrowserWorkbenchModule } from '@affine/core/modules/workbench'; +import { + configureBrowserWorkspaceFlavours, + configureIndexedDBWorkspaceEngineStorageProvider, +} from '@affine/core/modules/workspace-engine'; +import { + performanceLogger, + performanceRenderLogger, +} from '@affine/core/shared'; +import { Telemetry } from '@affine/core/telemetry'; +import { createI18n, setUpLanguage } from '@affine/i18n'; +import { + Framework, + FrameworkRoot, + getCurrentStore, + LifecycleService, +} from '@toeverything/infra'; +import { Suspense } from 'react'; +import { RouterProvider } from 'react-router-dom'; + +import { router } from './router'; + +if (!environment.isBrowser && environment.isDebug) { + document.body.innerHTML = `

Don't run web entry in electron.

`; + throw new Error('Wrong distribution'); +} + +const future = { + v7_startTransition: true, +} as const; + +const performanceI18nLogger = performanceLogger.namespace('i18n'); + +async function loadLanguage() { + performanceI18nLogger.info('start'); + + const i18n = createI18n(); + document.documentElement.lang = i18n.language; + + performanceI18nLogger.info('set up'); + await setUpLanguage(i18n); + performanceI18nLogger.info('done'); +} + +let languageLoadingPromise: Promise | null = null; + +const framework = new Framework(); +configureCommonModules(framework); +configureBrowserWorkbenchModule(framework); +configureLocalStorageStateStorageImpls(framework); +configureBrowserWorkspaceFlavours(framework); +configureIndexedDBWorkspaceEngineStorageProvider(framework); +const frameworkProvider = framework.provider(); + +// setup application lifecycle events, and emit application start event +window.addEventListener('focus', () => { + frameworkProvider.get(LifecycleService).applicationFocus(); +}); +frameworkProvider.get(LifecycleService).applicationStart(); + +export function App() { + performanceRenderLogger.debug('App'); + + if (!languageLoadingPromise) { + languageLoadingPromise = loadLanguage().catch(console.error); + } + + return ( + + + + + + } + router={router} + future={future} + /> + + + + ); +} diff --git a/packages/frontend/mobile/src/index.tsx b/packages/frontend/mobile/src/index.tsx new file mode 100644 index 0000000000..eeda0a5def --- /dev/null +++ b/packages/frontend/mobile/src/index.tsx @@ -0,0 +1,76 @@ +import './polyfill/dispose'; +import './polyfill/intl-segmenter'; +import './polyfill/promise-with-resolvers'; +import './polyfill/request-idle-callback'; +import '@affine/core/bootstrap/preload'; + +import { performanceLogger } from '@affine/core/shared'; +import { isDesktop } from '@affine/env/constant'; +import { + init, + reactRouterV6BrowserTracingIntegration, + setTags, +} from '@sentry/react'; +import { StrictMode, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + createRoutesFromChildren, + matchRoutes, + useLocation, + useNavigationType, +} from 'react-router-dom'; + +import { App } from './app'; + +const performanceMainLogger = performanceLogger.namespace('main'); +function main() { + performanceMainLogger.info('start'); + + // skip bootstrap setup for desktop onboarding + if (isDesktop && window.appInfo?.windowName === 'onboarding') { + performanceMainLogger.info('skip setup'); + } else { + performanceMainLogger.info('setup start'); + if (window.SENTRY_RELEASE || environment.isDebug) { + // https://docs.sentry.io/platforms/javascript/guides/react/#configure + init({ + dsn: process.env.SENTRY_DSN, + environment: process.env.BUILD_TYPE ?? 'development', + integrations: [ + reactRouterV6BrowserTracingIntegration({ + useEffect, + useLocation, + useNavigationType, + createRoutesFromChildren, + matchRoutes, + }), + ], + }); + setTags({ + appVersion: runtimeConfig.appVersion, + editorVersion: runtimeConfig.editorVersion, + }); + } + performanceMainLogger.info('setup done'); + } + + mountApp(); +} + +function mountApp() { + performanceMainLogger.info('import app'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const root = document.getElementById('app')!; + performanceMainLogger.info('render app'); + createRoot(root).render( + + + + ); +} + +try { + main(); +} catch (err) { + console.error('Failed to bootstrap app', err); +} diff --git a/packages/frontend/mobile/src/pages/404.tsx b/packages/frontend/mobile/src/pages/404.tsx new file mode 100644 index 0000000000..df4674933c --- /dev/null +++ b/packages/frontend/mobile/src/pages/404.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/404
; +}; diff --git a/packages/frontend/mobile/src/pages/auth.tsx b/packages/frontend/mobile/src/pages/auth.tsx new file mode 100644 index 0000000000..4b4d5d0eb0 --- /dev/null +++ b/packages/frontend/mobile/src/pages/auth.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/auth/*
; +}; diff --git a/packages/frontend/mobile/src/pages/index.tsx b/packages/frontend/mobile/src/pages/index.tsx new file mode 100644 index 0000000000..508c375741 --- /dev/null +++ b/packages/frontend/mobile/src/pages/index.tsx @@ -0,0 +1,8 @@ +import { Component as IndexComponent } from '@affine/core/pages/index'; + +// Default route fallback for mobile + +export const Component = () => { + // TODO: replace with a mobile version + return ; +}; diff --git a/packages/frontend/mobile/src/pages/sign-in.tsx b/packages/frontend/mobile/src/pages/sign-in.tsx new file mode 100644 index 0000000000..3687f114f1 --- /dev/null +++ b/packages/frontend/mobile/src/pages/sign-in.tsx @@ -0,0 +1,7 @@ +// Default route fallback for mobile +import { SignIn } from '@affine/core/pages/sign-in'; + +export const Component = () => { + // placeholder impl + return ; +}; diff --git a/packages/frontend/mobile/src/pages/workspace/all.tsx b/packages/frontend/mobile/src/pages/workspace/all.tsx new file mode 100644 index 0000000000..e2ec20a6b9 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/all.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/workspace/:workspaceId/all
; +}; diff --git a/packages/frontend/mobile/src/pages/workspace/collection/detail.tsx b/packages/frontend/mobile/src/pages/workspace/collection/detail.tsx new file mode 100644 index 0000000000..a098207e75 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/collection/detail.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/workspace/:workspaceId/collection/:collectionId
; +}; diff --git a/packages/frontend/mobile/src/pages/workspace/collection/index.tsx b/packages/frontend/mobile/src/pages/workspace/collection/index.tsx new file mode 100644 index 0000000000..bdc53f76d7 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/collection/index.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/workspace/:workspaceId/collection
; +}; diff --git a/packages/frontend/mobile/src/pages/workspace/detail.tsx b/packages/frontend/mobile/src/pages/workspace/detail.tsx new file mode 100644 index 0000000000..f6c32eba56 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/detail.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/workspace/:workspaceId/:pageId
; +}; diff --git a/packages/frontend/mobile/src/pages/workspace/index.tsx b/packages/frontend/mobile/src/pages/workspace/index.tsx new file mode 100644 index 0000000000..8319186640 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/index.tsx @@ -0,0 +1,112 @@ +import { AppFallback } from '@affine/core/components/affine/app-container'; +import { RouteContainer } from '@affine/core/modules/workbench/view/route-container'; +import { PageNotFound } from '@affine/core/pages/404'; +import { MobileWorkbenchRoot } from '@affine/core/pages/workspace/workbench-root'; +import { + useLiveData, + useServices, + WorkspacesService, +} from '@toeverything/infra'; +import { lazy as reactLazy, useEffect, useMemo, useState } from 'react'; +import { matchPath, useLocation, useParams } from 'react-router-dom'; + +import { viewRoutes } from '../../router'; +import { WorkspaceLayout } from './layout'; + +const warpedRoutes = viewRoutes.map(({ path, lazy }) => { + const Component = reactLazy(() => + lazy().then(m => ({ + default: m.Component as React.ComponentType, + })) + ); + const route = { + Component, + }; + + return { + path, + Component: () => { + return ; + }, + }; +}); + +export const Component = () => { + const { workspacesService } = useServices({ + WorkspacesService, + }); + + const params = useParams(); + const location = useLocation(); + + // todo(pengx17): dedupe the code with core + // check if we are in detail doc route, if so, maybe render share page + const detailDocRoute = useMemo(() => { + const match = matchPath( + '/workspace/:workspaceId/:docId', + location.pathname + ); + if ( + match && + match.params.docId && + match.params.workspaceId && + // TODO(eyhn): need a better way to check if it's a docId + viewRoutes.find(route => matchPath(route.path, '/' + match.params.docId)) + ?.path === '/:pageId' + ) { + return { + docId: match.params.docId, + workspaceId: match.params.workspaceId, + }; + } else { + return null; + } + }, [location.pathname]); + + const [workspaceNotFound, setWorkspaceNotFound] = useState(false); + const listLoading = useLiveData(workspacesService.list.isRevalidating$); + const workspaces = useLiveData(workspacesService.list.workspaces$); + const meta = useMemo(() => { + return workspaces.find(({ id }) => id === params.workspaceId); + }, [workspaces, params.workspaceId]); + + // if listLoading is false, we can show 404 page, otherwise we should show loading page. + useEffect(() => { + if (listLoading === false && meta === undefined) { + setWorkspaceNotFound(true); + } + if (meta) { + setWorkspaceNotFound(false); + } + }, [listLoading, meta, workspacesService]); + + // if workspace is not found, we should revalidate in interval + useEffect(() => { + if (listLoading === false && meta === undefined) { + const timer = setInterval( + () => workspacesService.list.revalidate(), + 5000 + ); + return () => clearInterval(timer); + } + return; + }, [listLoading, meta, workspaceNotFound, workspacesService]); + + if (workspaceNotFound) { + if ( + detailDocRoute /* */ && + environment.isBrowser /* only browser has share page */ + ) { + return
TODO: share page
; + } + return ; + } + if (!meta) { + return ; + } + return ( + + + + ); +}; diff --git a/packages/frontend/mobile/src/pages/workspace/layout.tsx b/packages/frontend/mobile/src/pages/workspace/layout.tsx new file mode 100644 index 0000000000..5a29217f50 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/layout.tsx @@ -0,0 +1,92 @@ +import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary'; +import { AppFallback } from '@affine/core/components/affine/app-container'; +import { WorkspaceLayoutProviders } from '@affine/core/layouts/workspace-layout'; +import { + AllWorkspaceModals, + CurrentWorkspaceModals, +} from '@affine/core/providers/modal-provider'; +import { SWRConfigProvider } from '@affine/core/providers/swr-config-provider'; +import type { Workspace, WorkspaceMetadata } from '@toeverything/infra'; +import { + FrameworkScope, + GlobalContextService, + useLiveData, + useServices, + WorkspacesService, +} from '@toeverything/infra'; +import { + type PropsWithChildren, + useEffect, + useLayoutEffect, + useState, +} from 'react'; + +export const WorkspaceLayout = ({ + meta, + children, +}: PropsWithChildren<{ meta: WorkspaceMetadata }>) => { + // todo: reduce code duplication with packages\frontend\core\src\pages\workspace\index.tsx + const { workspacesService, globalContextService } = useServices({ + WorkspacesService, + GlobalContextService, + }); + + const [workspace, setWorkspace] = useState(null); + + useLayoutEffect(() => { + const ref = workspacesService.open({ metadata: meta }); + setWorkspace(ref.workspace); + return () => { + ref.dispose(); + }; + }, [meta, workspacesService]); + + useEffect(() => { + if (workspace) { + // for debug purpose + window.currentWorkspace = workspace ?? undefined; + window.dispatchEvent( + new CustomEvent('affine:workspace:change', { + detail: { + id: workspace.id, + }, + }) + ); + localStorage.setItem('last_workspace_id', workspace.id); + globalContextService.globalContext.workspaceId.set(workspace.id); + return () => { + window.currentWorkspace = undefined; + globalContextService.globalContext.workspaceId.set(null); + }; + } + return; + }, [globalContextService, workspace]); + + const isRootDocReady = + useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false; + + if (!workspace) { + return null; // skip this, workspace will be set in layout effect + } + + if (!isRootDocReady) { + return ( + + + + + ); + } + + return ( + + + + + + {children} + + + + ); +}; diff --git a/packages/frontend/mobile/src/pages/workspace/tag/detail.tsx b/packages/frontend/mobile/src/pages/workspace/tag/detail.tsx new file mode 100644 index 0000000000..2aa1bda3a6 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/tag/detail.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/workspace/:workspaceId/tag/:tagId
; +}; diff --git a/packages/frontend/mobile/src/pages/workspace/tag/index.tsx b/packages/frontend/mobile/src/pages/workspace/tag/index.tsx new file mode 100644 index 0000000000..9433819f81 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/tag/index.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/workspace/:workspaceId/tag
; +}; diff --git a/packages/frontend/mobile/src/pages/workspace/trash.tsx b/packages/frontend/mobile/src/pages/workspace/trash.tsx new file mode 100644 index 0000000000..86415ad779 --- /dev/null +++ b/packages/frontend/mobile/src/pages/workspace/trash.tsx @@ -0,0 +1,3 @@ +export const Component = () => { + return
/workspace/:workspaceId/trash
; +}; diff --git a/packages/frontend/mobile/src/polyfill/dispose.ts b/packages/frontend/mobile/src/polyfill/dispose.ts new file mode 100644 index 0000000000..615ed233c7 --- /dev/null +++ b/packages/frontend/mobile/src/polyfill/dispose.ts @@ -0,0 +1,2 @@ +import 'core-js/modules/esnext.symbol.async-dispose'; +import 'core-js/modules/esnext.symbol.dispose'; diff --git a/packages/frontend/mobile/src/polyfill/intl-segmenter.ts b/packages/frontend/mobile/src/polyfill/intl-segmenter.ts new file mode 100644 index 0000000000..f493cd793e --- /dev/null +++ b/packages/frontend/mobile/src/polyfill/intl-segmenter.ts @@ -0,0 +1,11 @@ +if (Intl.Segmenter === undefined) { + await import('intl-segmenter-polyfill-rs').then(({ Segmenter }) => { + Object.defineProperty(Intl, 'Segmenter', { + value: Segmenter, + configurable: true, + writable: true, + }); + }); +} + +export {}; diff --git a/packages/frontend/mobile/src/polyfill/promise-with-resolvers.ts b/packages/frontend/mobile/src/polyfill/promise-with-resolvers.ts new file mode 100644 index 0000000000..6d806c3a08 --- /dev/null +++ b/packages/frontend/mobile/src/polyfill/promise-with-resolvers.ts @@ -0,0 +1 @@ +import 'core-js/features/promise/with-resolvers'; diff --git a/packages/frontend/mobile/src/polyfill/request-idle-callback.ts b/packages/frontend/mobile/src/polyfill/request-idle-callback.ts new file mode 100644 index 0000000000..e3156d20df --- /dev/null +++ b/packages/frontend/mobile/src/polyfill/request-idle-callback.ts @@ -0,0 +1,19 @@ +window.requestIdleCallback = + window.requestIdleCallback || + function (cb) { + const start = Date.now(); + return setTimeout(function () { + cb({ + didTimeout: false, + timeRemaining: function () { + return Math.max(0, 50 - (Date.now() - start)); + }, + }); + }, 1); + }; + +window.cancelIdleCallback = + window.cancelIdleCallback || + function (id) { + clearTimeout(id); + }; diff --git a/packages/frontend/mobile/src/polyfill/set-immediate.ts b/packages/frontend/mobile/src/polyfill/set-immediate.ts new file mode 100644 index 0000000000..b8f042569a --- /dev/null +++ b/packages/frontend/mobile/src/polyfill/set-immediate.ts @@ -0,0 +1 @@ +import 'setimmediate'; diff --git a/packages/frontend/mobile/src/router.tsx b/packages/frontend/mobile/src/router.tsx new file mode 100644 index 0000000000..11f3130d01 --- /dev/null +++ b/packages/frontend/mobile/src/router.tsx @@ -0,0 +1,95 @@ +import { RootRouter } from '@affine/core/router'; +import { wrapCreateBrowserRouter } from '@sentry/react'; +import type { RouteObject } from 'react-router-dom'; +import { + createBrowserRouter as reactRouterCreateBrowserRouter, + redirect, +} from 'react-router-dom'; + +export const topLevelRoutes = [ + { + element: , + children: [ + { + path: '/', + lazy: () => import('./pages/index'), + }, + { + path: '/workspace/:workspaceId/*', + lazy: () => import('./pages/workspace/index'), + }, + { + path: '/share/:workspaceId/:pageId', + loader: ({ params }) => { + return redirect(`/workspace/${params.workspaceId}/${params.pageId}`); + }, + }, + { + path: '/404', + lazy: () => import('./pages/404'), + }, + { + path: '/auth/:authType', + lazy: () => import('./pages/auth'), + }, + { + path: '/sign-in', + lazy: () => import('./pages/sign-in'), + }, + { + path: '/redirect-proxy', + lazy: () => import('@affine/core/pages/redirect'), + }, + { + path: '*', + lazy: () => import('./pages/404'), + }, + ], + }, +] satisfies [RouteObject, ...RouteObject[]]; + +export const viewRoutes = [ + { + path: '/all', + lazy: () => import('./pages/workspace/all'), + }, + { + path: '/collection', + lazy: () => import('./pages/workspace/collection/index'), + }, + { + path: '/collection/:collectionId', + lazy: () => import('./pages/workspace/collection/detail'), + }, + { + path: '/tag', + lazy: () => import('./pages/workspace/tag/index'), + }, + { + path: '/tag/:tagId', + lazy: () => import('./pages/workspace/tag/detail'), + }, + { + path: '/trash', + lazy: () => import('./pages/workspace/trash'), + }, + { + path: '/:pageId', + lazy: () => import('./pages/workspace/detail'), + }, + { + path: '*', + lazy: () => import('./pages/404'), + }, +] satisfies [RouteObject, ...RouteObject[]]; + +const createBrowserRouter = wrapCreateBrowserRouter( + reactRouterCreateBrowserRouter +); +export const router = ( + window.SENTRY_RELEASE ? createBrowserRouter : reactRouterCreateBrowserRouter +)(topLevelRoutes, { + future: { + v7_normalizeFormMethod: true, + }, +}); diff --git a/packages/frontend/mobile/tsconfig.json b/packages/frontend/mobile/tsconfig.json new file mode 100644 index 0000000000..af59eddffb --- /dev/null +++ b/packages/frontend/mobile/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "lib", + "moduleResolution": "Bundler", + "types": ["affine__env"], + "rootDir": "./src" + }, + "include": ["./src"], + "references": [{ "path": "../core" }] +} diff --git a/tools/cli/src/bin/dev.ts b/tools/cli/src/bin/dev.ts index 12895cab50..e68726740c 100644 --- a/tools/cli/src/bin/dev.ts +++ b/tools/cli/src/bin/dev.ts @@ -49,6 +49,9 @@ const buildFlags = process.argv.includes('--static') { value: 'admin', }, + { + value: 'mobile', + }, ], initialValue: 'browser', }), diff --git a/tools/cli/src/config/cwd.cjs b/tools/cli/src/config/cwd.cjs index dbffed6657..13e44913a6 100644 --- a/tools/cli/src/config/cwd.cjs +++ b/tools/cli/src/config/cwd.cjs @@ -23,6 +23,8 @@ module.exports.getCwdFromDistribution = function getCwdFromDistribution( return join(projectRoot, 'packages/frontend/electron/renderer'); case 'admin': return join(projectRoot, 'packages/frontend/admin'); + case 'mobile': + return join(projectRoot, 'packages/frontend/mobile'); default: { throw new Error('DISTRIBUTION must be one of browser, desktop'); } diff --git a/tools/cli/src/config/index.ts b/tools/cli/src/config/index.ts index 0035fb39da..534bbdb9f0 100644 --- a/tools/cli/src/config/index.ts +++ b/tools/cli/src/config/index.ts @@ -1,5 +1,5 @@ export type BuildFlags = { - distribution: 'browser' | 'desktop' | 'admin'; + distribution: 'browser' | 'desktop' | 'admin' | 'mobile'; mode: 'development' | 'production'; channel: 'stable' | 'beta' | 'canary' | 'internal'; coverage?: boolean; diff --git a/yarn.lock b/yarn.lock index 437951cb5b..1a5e0d6038 100644 --- a/yarn.lock +++ b/yarn.lock @@ -637,6 +637,26 @@ __metadata: languageName: unknown linkType: soft +"@affine/mobile@workspace:packages/frontend/mobile": + version: 0.0.0-use.local + resolution: "@affine/mobile@workspace:packages/frontend/mobile" + dependencies: + "@affine/cli": "workspace:*" + "@affine/component": "workspace:*" + "@affine/core": "workspace:*" + "@affine/env": "workspace:*" + "@sentry/react": "npm:^8.0.0" + "@types/react": "npm:^18.2.75" + "@types/react-dom": "npm:^18.2.24" + core-js: "npm:^3.36.1" + cross-env: "npm:^7.0.3" + intl-segmenter-polyfill-rs: "npm:^0.1.7" + react: "npm:^18.2.0" + react-dom: "npm:^18.2.0" + typescript: "npm:^5.4.5" + languageName: unknown + linkType: soft + "@affine/monorepo@workspace:.": version: 0.0.0-use.local resolution: "@affine/monorepo@workspace:."