feat(core): share in workspace link (#7897)

ShareDocsService -> ShareDocsListService
ShareService -> ShareInfoService
(*new) ShareReaderService

`/share/:workspaceId/:docId` -> redirect to -> `/workspace/:workspaceId/:docId`

workspace loading process

1. find workspace in workspace list
2. (if not found) revalidate workspace list
3. (if still not found) try load share page
4. (if share page found) => share page
5. (if share page not found) => 404
6. (if workspace found) => workspace page
This commit is contained in:
EYHN
2024-08-16 09:12:18 +00:00
parent 620d20710a
commit 83716c2fd9
39 changed files with 431 additions and 267 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ export const Component = () => {
const workspacesService = useService(WorkspacesService);
const list = useLiveData(workspacesService.list.workspaces$);
const listIsLoading = useLiveData(workspacesService.list.isLoading$);
const listIsLoading = useLiveData(workspacesService.list.isRevalidating$);
const { openPage, jumpToPage } = useNavigateHelper();
const [searchParams] = useSearchParams();
@@ -1,17 +1,17 @@
import { AppFallback } from '@affine/core/components/affine/app-container';
import { useWorkspace } from '@affine/core/hooks/use-workspace';
import { viewRoutes } from '@affine/core/router';
import { ZipTransformer } from '@blocksuite/blocks';
import type { Workspace } from '@toeverything/infra';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import {
FrameworkScope,
GlobalContextService,
useLiveData,
useService,
useServices,
WorkspacesService,
} from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { matchPath, useLocation, useParams } from 'react-router-dom';
import { AffineErrorBoundary } from '../../components/affine/affine-error-boundary';
import { WorkspaceLayout } from '../../layouts/workspace-layout';
@@ -19,6 +19,7 @@ import { WorkbenchRoot } from '../../modules/workbench';
import { AllWorkspaceModals } from '../../providers/modal-provider';
import { performanceRenderLogger } from '../../shared';
import { PageNotFound } from '../404';
import { SharePage } from './share/share-page';
declare global {
/**
@@ -37,24 +38,104 @@ declare global {
export const Component = (): ReactElement => {
performanceRenderLogger.debug('WorkspaceLayout');
const { workspacesService } = useServices({
WorkspacesService,
});
const params = useParams();
const location = useLocation();
const [showNotFound, setShowNotFound] = useState(false);
const workspacesService = useService(WorkspacesService);
const listLoading = useLiveData(workspacesService.list.isLoading$);
// 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]);
const workspace = useWorkspace(meta);
const globalContext = useService(GlobalContextService).globalContext;
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
useEffect(() => {
workspacesService.list.revalidate();
}, [workspacesService]);
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 (
<SharePage
docId={detailDocRoute.docId}
workspaceId={detailDocRoute.workspaceId}
/>
);
}
return <PageNotFound noPermission />;
}
if (!meta) {
return <AppFallback key="workspaceLoading" />;
}
return <WorkspacePage meta={meta} />;
};
const WorkspacePage = ({ meta }: { meta: WorkspaceMetadata }) => {
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]);
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
useEffect(() => {
if (workspace) {
@@ -108,46 +189,17 @@ export const Component = (): ReactElement => {
input.click();
};
localStorage.setItem('last_workspace_id', workspace.id);
globalContext.workspaceId.set(workspace.id);
globalContextService.globalContext.workspaceId.set(workspace.id);
return () => {
window.currentWorkspace = undefined;
globalContext.workspaceId.set(null);
globalContextService.globalContext.workspaceId.set(null);
};
}
return;
}, [globalContext, meta, workspace]);
}, [globalContextService, workspace]);
// avoid doing operation, before workspace is loaded
const isRootDocReady =
useLiveData(workspace?.engine.rootDocState$.map(v => v.ready)) ?? false;
// if listLoading is false, we can show 404 page, otherwise we should show loading page.
useEffect(() => {
if (listLoading === false && meta === undefined) {
setShowNotFound(true);
}
if (meta) {
setShowNotFound(false);
}
}, [listLoading, meta, workspacesService]);
useEffect(() => {
if (showNotFound) {
const timer = setInterval(() => {
workspacesService.list.revalidate();
}, 3000);
return () => {
clearInterval(timer);
};
}
return;
}, [showNotFound, workspacesService]);
if (showNotFound) {
return <PageNotFound noPermission />;
}
if (!workspace) {
return <AppFallback key="workspaceLoading" />;
return null; // skip this, workspace will be set in layout effect
}
if (!isRootDocReady) {
@@ -1,9 +1,9 @@
import { BlocksuiteHeaderTitle } from '@affine/core/components/blocksuite/block-suite-header/title';
import { EditorModeSwitch } from '@affine/core/components/blocksuite/block-suite-mode-switch';
import ShareHeaderRightItem from '@affine/core/components/cloud/share-header-right-item';
import type { DocCollection } from '@blocksuite/store';
import type { DocMode } from '@toeverything/infra';
import { BlocksuiteHeaderTitle } from '../../components/blocksuite/block-suite-header/title/index';
import * as styles from './share-header.css';
export function ShareHeader({
@@ -1,8 +1,15 @@
import { Scrollable } from '@affine/component';
import { AppFallback } from '@affine/core/components/affine/app-container';
import { PageDetailEditor } from '@affine/core/components/page-detail-editor';
import { SharePageNotFoundError } from '@affine/core/components/share-page-not-found-error';
import { AppContainer, MainContainer } from '@affine/core/components/workspace';
import { useActiveBlocksuiteEditor } from '@affine/core/hooks/use-block-suite-editor';
import { usePageDocumentTitle } from '@affine/core/hooks/use-global-state';
import { AuthService } from '@affine/core/modules/cloud';
import { type Editor, EditorsService } from '@affine/core/modules/editor';
import { PeekViewManagerModal } from '@affine/core/modules/peek-view';
import { ShareReaderService } from '@affine/core/modules/share-doc';
import { CloudBlobStorage } from '@affine/core/modules/workspace-engine';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { noop } from '@blocksuite/global/utils';
@@ -17,118 +24,81 @@ import {
ReadonlyDocStorage,
useLiveData,
useService,
WorkspaceFlavourProvider,
useServices,
WorkspacesService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useEffect, useState } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import {
isRouteErrorResponse,
redirect,
useLoaderData,
useRouteError,
} from 'react-router-dom';
import { AppContainer } from '../../components/affine/app-container';
import { PageDetailEditor } from '../../components/page-detail-editor';
import { SharePageNotFoundError } from '../../components/share-page-not-found-error';
import { MainContainer } from '../../components/workspace';
import { PeekViewManagerModal } from '../../modules/peek-view';
import { CloudBlobStorage } from '../../modules/workspace-engine/impls/engine/blob-cloud';
import * as styles from './share-detail-page.css';
import { PageNotFound } from '../../404';
import { ShareFooter } from './share-footer';
import { ShareHeader } from './share-header';
import * as styles from './share-page.css';
type DocPublishMode = 'edgeless' | 'page';
export type CloudDoc = {
arrayBuffer: ArrayBuffer;
publishMode: DocPublishMode;
};
export async function downloadBinaryFromCloud(
rootGuid: string,
pageGuid: string
): Promise<CloudDoc | null> {
const response = await fetch(`/api/workspaces/${rootGuid}/docs/${pageGuid}`);
if (response.ok) {
const publishMode = (response.headers.get('publish-mode') ||
'page') as DocPublishMode;
const arrayBuffer = await response.arrayBuffer();
// return both arrayBuffer and publish mode
return { arrayBuffer, publishMode };
}
return null;
}
type LoaderData = {
pageId: string;
export const SharePage = ({
workspaceId,
docId,
}: {
workspaceId: string;
publishMode: DocMode;
pageArrayBuffer: ArrayBuffer;
workspaceArrayBuffer: ArrayBuffer;
};
docId: string;
}) => {
const { shareReaderService } = useServices({
ShareReaderService,
});
function assertDownloadResponse(
value: CloudDoc | null
): asserts value is CloudDoc {
if (
!value ||
!((value as CloudDoc).arrayBuffer instanceof ArrayBuffer) ||
typeof (value as CloudDoc).publishMode !== 'string'
) {
throw new Error('value is not a valid download response');
}
}
const isLoading = useLiveData(shareReaderService.reader.isLoading$);
const error = useLiveData(shareReaderService.reader.error$);
const data = useLiveData(shareReaderService.reader.data$);
export const loader: LoaderFunction = async ({ params }) => {
const workspaceId = params?.workspaceId;
const pageId = params?.pageId;
if (!workspaceId || !pageId) {
return redirect('/404');
useEffect(() => {
shareReaderService.reader.loadShare({ workspaceId, docId });
}, [shareReaderService, docId, workspaceId]);
if (isLoading) {
return <AppFallback />;
}
const [workspaceResponse, pageResponse] = await Promise.all([
downloadBinaryFromCloud(workspaceId, workspaceId),
downloadBinaryFromCloud(workspaceId, pageId),
]);
assertDownloadResponse(workspaceResponse);
const { arrayBuffer: workspaceArrayBuffer } = workspaceResponse;
assertDownloadResponse(pageResponse);
const { arrayBuffer: pageArrayBuffer, publishMode } = pageResponse;
if (error) {
// TODO(@eyhn): show error details
return <SharePageNotFoundError />;
}
return {
workspaceId,
pageId,
publishMode,
workspaceArrayBuffer,
pageArrayBuffer,
} satisfies LoaderData;
if (data) {
return (
<SharePageInner
workspaceId={data.workspaceId}
docId={data.docId}
workspaceBinary={data.workspaceBinary}
docBinary={data.docBinary}
publishMode={data.publishMode}
/>
);
} else {
return <PageNotFound noPermission />;
}
};
export const Component = () => {
const {
workspaceId,
pageId: docId,
publishMode,
workspaceArrayBuffer,
pageArrayBuffer,
} = useLoaderData() as LoaderData;
const SharePageInner = ({
workspaceId,
docId,
workspaceBinary,
docBinary,
publishMode = 'page',
}: {
workspaceId: string;
docId: string;
workspaceBinary: Uint8Array;
docBinary: Uint8Array;
publishMode?: DocMode;
}) => {
const t = useI18n();
const workspacesService = useService(WorkspacesService);
const t = useI18n();
const [workspace, setWorkspace] = useState<Workspace | null>(null);
const [page, setPage] = useState<Doc | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
const [_, setActiveBlocksuiteEditor] = useActiveBlocksuiteEditor();
const defaultCloudProvider = workspacesService.framework.get(
WorkspaceFlavourProvider('CLOUD')
);
useEffect(() => {
// create a workspace for share page
const { workspace } = workspacesService.open(
@@ -140,28 +110,23 @@ export const Component = () => {
isSharedMode: true,
},
{
...defaultCloudProvider,
getEngineProvider(workspaceId) {
return {
getDocStorage() {
return new ReadonlyDocStorage({
[workspaceId]: new Uint8Array(workspaceArrayBuffer),
[docId]: new Uint8Array(pageArrayBuffer),
});
},
getAwarenessConnections() {
return [];
},
getDocServer() {
return null;
},
getLocalBlobStorage() {
return EmptyBlobStorage;
},
getRemoteBlobStorages() {
return [new CloudBlobStorage(workspaceId)];
},
};
getDocStorage() {
return new ReadonlyDocStorage({
[workspaceId]: workspaceBinary,
[docId]: docBinary,
});
},
getAwarenessConnections() {
return [];
},
getDocServer() {
return null;
},
getLocalBlobStorage() {
return EmptyBlobStorage;
},
getRemoteBlobStorages() {
return [new CloudBlobStorage(workspaceId)];
},
}
);
@@ -188,13 +153,12 @@ export const Component = () => {
console.error(err);
});
}, [
defaultCloudProvider,
pageArrayBuffer,
docId,
workspaceArrayBuffer,
workspaceId,
workspacesService,
publishMode,
workspaceBinary,
docBinary,
]);
const pageTitle = useLiveData(page?.title$);
@@ -269,14 +233,3 @@ export const Component = () => {
</FrameworkScope>
);
};
export function ErrorBoundary() {
const error = useRouteError();
return isRouteErrorResponse(error) ? (
<h1>
{error.status} {error.statusText}
</h1>
) : (
<SharePageNotFoundError />
);
}