mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-02 06:39:46 +08:00
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:
@@ -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
-1
@@ -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({
|
||||
+78
-125
@@ -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 />
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user