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

View File

@@ -1,7 +1,7 @@
import { Button } from '@affine/component/ui/button';
import { Divider } from '@affine/component/ui/divider';
import { Menu } from '@affine/component/ui/menu';
import { ShareService } from '@affine/core/modules/share-doc';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useI18n } from '@affine/i18n';
import { WebIcon } from '@blocksuite/icons/rc';
@@ -48,12 +48,12 @@ const DefaultShareButton = forwardRef(function DefaultShareButton(
ref: Ref<HTMLButtonElement>
) {
const t = useI18n();
const shareService = useService(ShareService);
const shared = useLiveData(shareService.share.isShared$);
const shareInfoService = useService(ShareInfoService);
const shared = useLiveData(shareInfoService.shareInfo.isShared$);
useEffect(() => {
shareService.share.revalidate();
}, [shareService]);
shareInfoService.shareInfo.revalidate();
}, [shareInfoService]);
return (
<Button ref={ref} className={styles.shareButton} variant="primary">

View File

@@ -6,7 +6,7 @@ import { useSharingUrl } from '@affine/core/hooks/affine/use-share-url';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { track } from '@affine/core/mixpanel';
import { ServerConfigService } from '@affine/core/modules/cloud';
import { ShareService } from '@affine/core/modules/share-doc';
import { ShareInfoService } from '@affine/core/modules/share-doc';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { PublicPageMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
@@ -59,13 +59,13 @@ export const AffineSharePage = (props: ShareMenuProps) => {
workspaceMetadata: { id: workspaceId },
} = props;
const doc = useService(DocService).doc;
const shareService = useService(ShareService);
const shareInfoService = useService(ShareInfoService);
const serverConfig = useService(ServerConfigService).serverConfig;
useEffect(() => {
shareService.share.revalidate();
}, [shareService]);
const isSharedPage = useLiveData(shareService.share.isShared$);
const sharedMode = useLiveData(shareService.share.sharedMode$);
shareInfoService.shareInfo.revalidate();
}, [shareInfoService]);
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
const sharedMode = useLiveData(shareInfoService.shareInfo.sharedMode$);
const baseUrl = useLiveData(serverConfig.config$.map(c => c?.baseUrl));
const isLoading =
isSharedPage === null || sharedMode === null || baseUrl === null;
@@ -103,7 +103,7 @@ export const AffineSharePage = (props: ShareMenuProps) => {
const onClickCreateLink = useAsyncCallback(async () => {
try {
await shareService.share.enableShare(
await shareInfoService.shareInfo.enableShare(
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page
);
track.$.sharePanel.$.createShareLink({
@@ -139,11 +139,11 @@ export const AffineSharePage = (props: ShareMenuProps) => {
});
console.error(err);
}
}, [mode, shareService.share, sharingUrl, t]);
}, [mode, shareInfoService.shareInfo, sharingUrl, t]);
const onDisablePublic = useAsyncCallback(async () => {
try {
await shareService.share.disableShare();
await shareInfoService.shareInfo.disableShare();
notify.error({
title:
t[
@@ -168,13 +168,13 @@ export const AffineSharePage = (props: ShareMenuProps) => {
console.log(err);
}
setShowDisable(false);
}, [shareService, t]);
}, [shareInfoService, t]);
const onShareModeChange = useAsyncCallback(
async (value: DocMode) => {
try {
if (isSharedPage) {
await shareService.share.changeShare(
await shareInfoService.shareInfo.changeShare(
value === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page
);
notify.success({
@@ -208,7 +208,7 @@ export const AffineSharePage = (props: ShareMenuProps) => {
console.error(err);
}
},
[isSharedPage, shareService.share, t]
[isSharedPage, shareInfoService.shareInfo, t]
);
if (isLoading) {

View File

@@ -1,5 +1,5 @@
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { Collection, Filter } from '@affine/env/filter';
import { PublicPageMode } from '@affine/graphql';
import type { DocMeta } from '@blocksuite/store';
@@ -16,8 +16,8 @@ export const useFilteredPageMetas = (
collection?: Collection;
} = {}
) => {
const shareDocsService = useService(ShareDocsService);
const shareDocs = useLiveData(shareDocsService.shareDocs?.list$);
const shareDocsListService = useService(ShareDocsListService);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const getPublicMode = useCallback(
(id: string) => {
@@ -33,8 +33,8 @@ export const useFilteredPageMetas = (
useEffect(() => {
// TODO(@eyhn): loading & error UI
shareDocsService.shareDocs?.revalidate();
}, [shareDocsService]);
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const favAdapter = useService(CompatibleFavoriteItemsAdapter);
const favoriteItems = useLiveData(favAdapter.favorites$);

View File

@@ -1,7 +1,7 @@
import { IconButton, Menu, toast } from '@affine/component';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { PublicPageMode } from '@affine/graphql';
import { Trans, useI18n } from '@affine/i18n';
import { FilterIcon } from '@blocksuite/icons/rc';
@@ -56,21 +56,24 @@ export const SelectPage = ({
const clearSelected = useCallback(() => {
onChange([]);
}, [onChange]);
const { workspaceService, compatibleFavoriteItemsAdapter, shareDocsService } =
useServices({
ShareDocsService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
});
const shareDocs = useLiveData(shareDocsService.shareDocs?.list$);
const {
workspaceService,
compatibleFavoriteItemsAdapter,
shareDocsListService,
} = useServices({
ShareDocsListService,
WorkspaceService,
CompatibleFavoriteItemsAdapter,
});
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const workspace = workspaceService.workspace;
const docCollection = workspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(docCollection);
const favourites = useLiveData(compatibleFavoriteItemsAdapter.favorites$);
useEffect(() => {
shareDocsService.shareDocs?.revalidate();
}, [shareDocsService.shareDocs]);
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService.shareDocs]);
const getPublicMode = useCallback(
(id: string) => {

View File

@@ -3,7 +3,7 @@ import type { AllPageListConfig } from '@affine/core/components/page-list';
import { FavoriteTag } from '@affine/core/components/page-list';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import { PublicPageMode } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/store';
@@ -15,13 +15,13 @@ import { useCallback, useEffect, useMemo } from 'react';
*/
export const useAllPageListConfig = () => {
const currentWorkspace = useService(WorkspaceService).workspace;
const shareDocService = useService(ShareDocsService);
const shareDocs = useLiveData(shareDocService.shareDocs?.list$);
const shareDocsListService = useService(ShareDocsListService);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
useEffect(() => {
// TODO(@eyhn): loading & error UI
shareDocService.shareDocs?.revalidate();
}, [shareDocService]);
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const workspace = currentWorkspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(workspace);

View File

@@ -228,10 +228,10 @@ const WorkspaceLayoutUIContainer = ({ children }: PropsWithChildren) => {
);
};
export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
const currentWorkspace = useService(WorkspaceService).workspace;
const workspace = useService(WorkspaceService).workspace;
const upgrading = useLiveData(currentWorkspace.upgrade.upgrading$);
const needUpgrade = useLiveData(currentWorkspace.upgrade.needUpgrade$);
const upgrading = useLiveData(workspace.upgrade.upgrading$);
const needUpgrade = useLiveData(workspace.upgrade.needUpgrade$);
return (
<WorkspaceLayoutProviders>

View File

@@ -1,5 +1,8 @@
export class NetworkError extends Error {
constructor(public readonly originError: Error) {
constructor(
public readonly originError: Error,
public readonly status?: number
) {
super(`Network error: ${originError.message}`);
this.stack = originError.stack;
}
@@ -10,7 +13,10 @@ export function isNetworkError(error: Error): error is NetworkError {
}
export class BackendError extends Error {
constructor(public readonly originError: Error) {
constructor(
public readonly originError: Error,
public readonly status?: number
) {
super(`Server error: ${originError.message}`);
this.stack = originError.stack;
}

View File

@@ -61,7 +61,7 @@ export class FetchService extends Service {
if (res.status === 504) {
const error = new Error('Gateway Timeout');
logger.debug('network error', error);
throw new NetworkError(error);
throw new NetworkError(error, res.status);
}
if (!res.ok) {
logger.warn(
@@ -76,7 +76,10 @@ export class FetchService extends Service {
// ignore
}
}
throw new BackendError(UserFriendlyError.fromAnyError(reason));
throw new BackendError(
UserFriendlyError.fromAnyError(reason),
res.status
);
}
return res;
};

View File

@@ -12,6 +12,6 @@ import { DocsSearchService } from './services/docs-search';
export function configureDocsSearchModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(DocsSearchService)
.service(DocsSearchService, [WorkspaceService])
.entity(DocsIndexer, [WorkspaceService]);
}

View File

@@ -1,3 +1,4 @@
import type { WorkspaceService } from '@toeverything/infra';
import {
fromPromise,
OnEvent,
@@ -12,7 +13,15 @@ import { DocsIndexer } from '../entities/docs-indexer';
export class DocsSearchService extends Service {
readonly indexer = this.framework.createEntity(DocsIndexer);
constructor(private readonly workspaceService: WorkspaceService) {
super();
}
handleWorkspaceEngineBeforeStart() {
// skip if in shared mode
if (this.workspaceService.workspace.openOptions.isSharedMode) {
return;
}
this.indexer.setupListener();
this.indexer.startCrawling();
}

View File

@@ -13,7 +13,7 @@ import {
import { track } from '@affine/core/mixpanel';
import { CollectionService } from '@affine/core/modules/collection';
import { CompatibleFavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import { ShareDocsListService } from '@affine/core/modules/share-doc';
import type { AffineDNDData } from '@affine/core/types/dnd';
import type { Collection } from '@affine/env/filter';
import { PublicPageMode } from '@affine/graphql';
@@ -247,19 +247,19 @@ const ExplorerCollectionNodeChildren = ({
const {
docsService,
compatibleFavoriteItemsAdapter,
shareDocsService,
shareDocsListService,
collectionService,
} = useServices({
DocsService,
CompatibleFavoriteItemsAdapter,
ShareDocsService,
ShareDocsListService,
CollectionService,
});
useEffect(() => {
// TODO(@eyhn): loading & error UI
shareDocsService.shareDocs?.revalidate();
}, [shareDocsService]);
shareDocsListService.shareDocs?.revalidate();
}, [shareDocsListService]);
const docMetas = useLiveData(
useMemo(
@@ -277,7 +277,7 @@ const ExplorerCollectionNodeChildren = ({
() => new Set(collection.allowList),
[collection.allowList]
);
const shareDocs = useLiveData(shareDocsService.shareDocs?.list$);
const shareDocs = useLiveData(shareDocsListService.shareDocs?.list$);
const handleRemoveFromAllowList = useCallback(
(id: string) => {

View File

@@ -21,7 +21,7 @@ import type { ShareStore } from '../stores/share';
type ShareInfoType = GetWorkspacePublicPageByIdQuery['workspace']['publicPage'];
export class Share extends Entity {
export class ShareInfo extends Entity {
info$ = new LiveData<ShareInfoType | undefined | null>(null);
isShared$ = this.info$.map(info =>
// null means not loaded yet, undefined means not shared

View File

@@ -0,0 +1,66 @@
import { UserFriendlyError } from '@affine/graphql';
import {
type DocMode,
effect,
Entity,
fromPromise,
LiveData,
onComplete,
onStart,
} from '@toeverything/infra';
import { catchError, EMPTY, mergeMap, switchMap } from 'rxjs';
import type { ShareReaderStore } from '../stores/share-reader';
export class ShareReader extends Entity {
isLoading$ = new LiveData<boolean>(false);
error$ = new LiveData<UserFriendlyError | null>(null);
data$ = new LiveData<{
workspaceId: string;
docId: string;
workspaceBinary: Uint8Array;
docBinary: Uint8Array;
// Used for old share server-side mode control
publishMode?: DocMode;
} | null>(null);
constructor(private readonly store: ShareReaderStore) {
super();
}
loadShare = effect(
switchMap(
({ workspaceId, docId }: { workspaceId: string; docId: string }) => {
return fromPromise(this.store.loadShare(workspaceId, docId)).pipe(
mergeMap(data => {
if (!data) {
this.data$.next(null);
} else {
this.data$.next({
workspaceId,
docId,
workspaceBinary: data.workspace,
docBinary: data.doc,
publishMode: data.publishMode ?? undefined,
});
}
return EMPTY;
}),
catchError((error: any) => {
this.error$.next(UserFriendlyError.fromAnyError(error));
return EMPTY;
}),
onStart(() => {
this.isLoading$.next(true);
this.data$.next(null);
this.error$.next(null);
}),
onComplete(() => {
this.isLoading$.next(false);
})
);
}
)
);
}

View File

@@ -1,5 +1,7 @@
export { ShareService } from './services/share';
export { ShareDocsService } from './services/share-docs';
export type { ShareReader } from './entities/share-reader';
export { ShareDocsListService } from './services/share-docs-list';
export { ShareInfoService } from './services/share-info';
export { ShareReaderService } from './services/share-reader';
import {
DocScope,
@@ -10,18 +12,24 @@ import {
WorkspaceService,
} from '@toeverything/infra';
import { GraphQLService } from '../cloud';
import { FetchService, GraphQLService } from '../cloud';
import { ShareDocsList } from './entities/share-docs-list';
import { Share } from './entities/share-info';
import { ShareService } from './services/share';
import { ShareDocsService } from './services/share-docs';
import { ShareInfo } from './entities/share-info';
import { ShareReader } from './entities/share-reader';
import { ShareDocsListService } from './services/share-docs-list';
import { ShareInfoService } from './services/share-info';
import { ShareReaderService } from './services/share-reader';
import { ShareStore } from './stores/share';
import { ShareDocsStore } from './stores/share-docs';
import { ShareReaderStore } from './stores/share-reader';
export function configureShareDocsModule(framework: Framework) {
framework
.service(ShareReaderService)
.entity(ShareReader, [ShareReaderStore])
.store(ShareReaderStore, [FetchService])
.scope(WorkspaceScope)
.service(ShareDocsService, [WorkspaceService])
.service(ShareDocsListService, [WorkspaceService])
.store(ShareDocsStore, [GraphQLService])
.entity(ShareDocsList, [
WorkspaceService,
@@ -29,7 +37,7 @@ export function configureShareDocsModule(framework: Framework) {
WorkspaceLocalCache,
])
.scope(DocScope)
.service(ShareService)
.entity(Share, [WorkspaceService, DocService, ShareStore])
.service(ShareInfoService)
.entity(ShareInfo, [WorkspaceService, DocService, ShareStore])
.store(ShareStore, [GraphQLService]);
}

View File

@@ -4,7 +4,7 @@ import { Service } from '@toeverything/infra';
import { ShareDocsList } from '../entities/share-docs-list';
export class ShareDocsService extends Service {
export class ShareDocsListService extends Service {
constructor(private readonly workspaceService: WorkspaceService) {
super();
}

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { ShareInfo } from '../entities/share-info';
export class ShareInfoService extends Service {
shareInfo = this.framework.createEntity(ShareInfo);
}

View File

@@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { ShareReader } from '../entities/share-reader';
export class ShareReaderService extends Service {
reader = this.framework.createEntity(ShareReader);
}

View File

@@ -1,7 +0,0 @@
import { Service } from '@toeverything/infra';
import { Share } from '../entities/share-info';
export class ShareService extends Service {
share = this.framework.createEntity(Share);
}

View File

@@ -0,0 +1,42 @@
import { ErrorNames, UserFriendlyError } from '@affine/graphql';
import { type DocMode, Store } from '@toeverything/infra';
import { type FetchService, isBackendError } from '../../cloud';
export class ShareReaderStore extends Store {
constructor(private readonly fetchService: FetchService) {
super();
}
async loadShare(workspaceId: string, docId: string) {
try {
const docResponse = await this.fetchService.fetch(
`/api/workspaces/${workspaceId}/docs/${docId}`
);
const publishMode = docResponse.headers.get(
'publish-mode'
) as DocMode | null;
const docBinary = await docResponse.arrayBuffer();
const workspaceResponse = await this.fetchService.fetch(
`/api/workspaces/${workspaceId}/docs/${workspaceId}`
);
const workspaceBinary = await workspaceResponse.arrayBuffer();
return {
doc: new Uint8Array(docBinary),
workspace: new Uint8Array(workspaceBinary),
publishMode,
};
} catch (error) {
if (
error instanceof Error &&
isBackendError(error) &&
UserFriendlyError.fromAnyError(error).name === ErrorNames.ACCESS_DENIED
) {
return null;
}
throw error;
}
}
}

View File

@@ -170,8 +170,8 @@ export class CloudWorkspaceFlavourProviderService
catchErrorInto(this.error$, err => {
logger.error('error to revalidate cloud workspaces', err);
}),
onStart(() => this.isLoading$.next(true)),
onComplete(() => this.isLoading$.next(false))
onStart(() => this.isRevalidating$.next(true)),
onComplete(() => this.isRevalidating$.next(false))
);
},
({ accountId }) => {
@@ -186,7 +186,7 @@ export class CloudWorkspaceFlavourProviderService
)
);
error$ = new LiveData<any>(null);
isLoading$ = new LiveData(false);
isRevalidating$ = new LiveData(false);
workspaces$ = new LiveData<WorkspaceMetadata[]>([]);
async getWorkspaceProfile(
id: string,
@@ -277,6 +277,6 @@ export class CloudWorkspaceFlavourProviderService
}
private waitForLoaded() {
return this.isLoading$.waitFor(loading => !loading);
return this.isRevalidating$.waitFor(loading => !loading);
}
}

View File

@@ -125,7 +125,7 @@ export class LocalWorkspaceFlavourProvider
}),
[]
);
isLoading$ = new LiveData(false);
isRevalidating$ = new LiveData(false);
revalidate(): void {
// notify livedata to re-scan workspaces
this.notifyChannel.postMessage(null);

View File

@@ -20,6 +20,8 @@ import {
} from './impls/local';
import { WorkspaceEngineStorageProvider } from './providers/engine';
export { CloudBlobStorage } from './impls/engine/blob-cloud';
export function configureBrowserWorkspaceFlavours(framework: Framework) {
framework
.impl(WorkspaceFlavourProvider('LOCAL'), LocalWorkspaceFlavourProvider, [

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();

View File

@@ -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) {

View File

@@ -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({

View File

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

View File

@@ -42,7 +42,9 @@ export const topLevelRoutes = [
},
{
path: '/share/:workspaceId/:pageId',
lazy: () => import('./pages/share/share-detail-page'),
loader: ({ params }) => {
return redirect(`/workspace/${params.workspaceId}/${params.pageId}`);
},
},
{
path: '/404',

View File

@@ -26,6 +26,13 @@ export class UserFriendlyError implements UserFriendlyErrorResponse {
return new UserFriendlyError(response.extensions);
}
if (
'originError' in response &&
response.originError instanceof UserFriendlyError
) {
return response.originError as UserFriendlyError;
}
if (
response &&
typeof response === 'object' &&