mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-17 22:37:04 +08:00
feat: init mobile entry (#7905)
This commit is contained in:
91
packages/frontend/mobile/src/app.tsx
Normal file
91
packages/frontend/mobile/src/app.tsx
Normal file
@@ -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 = `<h1 style="color:red;font-size:5rem;text-align:center;">Don't run web entry in electron.</h1>`;
|
||||
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<void> | 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 (
|
||||
<Suspense>
|
||||
<FrameworkRoot framework={frameworkProvider}>
|
||||
<AffineContext store={getCurrentStore()}>
|
||||
<Telemetry />
|
||||
<NotificationCenter />
|
||||
<RouterProvider
|
||||
fallbackElement={<AppFallback key="RouterFallback" />}
|
||||
router={router}
|
||||
future={future}
|
||||
/>
|
||||
</AffineContext>
|
||||
</FrameworkRoot>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
76
packages/frontend/mobile/src/index.tsx
Normal file
76
packages/frontend/mobile/src/index.tsx
Normal file
@@ -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(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
main();
|
||||
} catch (err) {
|
||||
console.error('Failed to bootstrap app', err);
|
||||
}
|
||||
3
packages/frontend/mobile/src/pages/404.tsx
Normal file
3
packages/frontend/mobile/src/pages/404.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/404</div>;
|
||||
};
|
||||
3
packages/frontend/mobile/src/pages/auth.tsx
Normal file
3
packages/frontend/mobile/src/pages/auth.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/auth/*</div>;
|
||||
};
|
||||
8
packages/frontend/mobile/src/pages/index.tsx
Normal file
8
packages/frontend/mobile/src/pages/index.tsx
Normal file
@@ -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 <IndexComponent />;
|
||||
};
|
||||
7
packages/frontend/mobile/src/pages/sign-in.tsx
Normal file
7
packages/frontend/mobile/src/pages/sign-in.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
// Default route fallback for mobile
|
||||
import { SignIn } from '@affine/core/pages/sign-in';
|
||||
|
||||
export const Component = () => {
|
||||
// placeholder impl
|
||||
return <SignIn />;
|
||||
};
|
||||
3
packages/frontend/mobile/src/pages/workspace/all.tsx
Normal file
3
packages/frontend/mobile/src/pages/workspace/all.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/all</div>;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/collection/:collectionId</div>;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/collection</div>;
|
||||
};
|
||||
3
packages/frontend/mobile/src/pages/workspace/detail.tsx
Normal file
3
packages/frontend/mobile/src/pages/workspace/detail.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/:pageId</div>;
|
||||
};
|
||||
112
packages/frontend/mobile/src/pages/workspace/index.tsx
Normal file
112
packages/frontend/mobile/src/pages/workspace/index.tsx
Normal file
@@ -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 <RouteContainer route={route} />;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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 <div>TODO: share page</div>;
|
||||
}
|
||||
return <PageNotFound noPermission />;
|
||||
}
|
||||
if (!meta) {
|
||||
return <AppFallback key="workspaceLoading" />;
|
||||
}
|
||||
return (
|
||||
<WorkspaceLayout meta={meta}>
|
||||
<MobileWorkbenchRoot routes={warpedRoutes} />
|
||||
</WorkspaceLayout>
|
||||
);
|
||||
};
|
||||
92
packages/frontend/mobile/src/pages/workspace/layout.tsx
Normal file
92
packages/frontend/mobile/src/pages/workspace/layout.tsx
Normal file
@@ -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<Workspace | null>(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 (
|
||||
<FrameworkScope scope={workspace.scope}>
|
||||
<AppFallback key="workspaceLoading" />
|
||||
<AllWorkspaceModals />
|
||||
</FrameworkScope>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FrameworkScope scope={workspace.scope}>
|
||||
<AffineErrorBoundary height="100vh">
|
||||
<SWRConfigProvider>
|
||||
<AllWorkspaceModals />
|
||||
<CurrentWorkspaceModals />
|
||||
<WorkspaceLayoutProviders>{children}</WorkspaceLayoutProviders>
|
||||
</SWRConfigProvider>
|
||||
</AffineErrorBoundary>
|
||||
</FrameworkScope>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/tag/:tagId</div>;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/tag</div>;
|
||||
};
|
||||
3
packages/frontend/mobile/src/pages/workspace/trash.tsx
Normal file
3
packages/frontend/mobile/src/pages/workspace/trash.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export const Component = () => {
|
||||
return <div>/workspace/:workspaceId/trash</div>;
|
||||
};
|
||||
2
packages/frontend/mobile/src/polyfill/dispose.ts
Normal file
2
packages/frontend/mobile/src/polyfill/dispose.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import 'core-js/modules/esnext.symbol.async-dispose';
|
||||
import 'core-js/modules/esnext.symbol.dispose';
|
||||
11
packages/frontend/mobile/src/polyfill/intl-segmenter.ts
Normal file
11
packages/frontend/mobile/src/polyfill/intl-segmenter.ts
Normal file
@@ -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 {};
|
||||
@@ -0,0 +1 @@
|
||||
import 'core-js/features/promise/with-resolvers';
|
||||
@@ -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);
|
||||
};
|
||||
1
packages/frontend/mobile/src/polyfill/set-immediate.ts
Normal file
1
packages/frontend/mobile/src/polyfill/set-immediate.ts
Normal file
@@ -0,0 +1 @@
|
||||
import 'setimmediate';
|
||||
95
packages/frontend/mobile/src/router.tsx
Normal file
95
packages/frontend/mobile/src/router.tsx
Normal file
@@ -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: <RootRouter />,
|
||||
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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user