feat: init mobile entry (#7905)

This commit is contained in:
pengx17
2024-08-21 13:17:35 +00:00
parent 3db95bafa2
commit 5acf1b5309
33 changed files with 744 additions and 28 deletions

View 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>
);
}

View 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);
}

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/404</div>;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/auth/*</div>;
};

View 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 />;
};

View 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 />;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/all</div>;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/collection/:collectionId</div>;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/collection</div>;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/:pageId</div>;
};

View 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>
);
};

View 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>
);
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/tag/:tagId</div>;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/tag</div>;
};

View File

@@ -0,0 +1,3 @@
export const Component = () => {
return <div>/workspace/:workspaceId/trash</div>;
};

View File

@@ -0,0 +1,2 @@
import 'core-js/modules/esnext.symbol.async-dispose';
import 'core-js/modules/esnext.symbol.dispose';

View 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 {};

View File

@@ -0,0 +1 @@
import 'core-js/features/promise/with-resolvers';

View File

@@ -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);
};

View File

@@ -0,0 +1 @@
import 'setimmediate';

View 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,
},
});