mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-25 18:26:05 +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:
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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$);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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 />
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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' &&
|
||||
|
||||
Reference in New Issue
Block a user