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

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

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

workspace loading process

1. find workspace in workspace list
2. (if not found) revalidate workspace list
3. (if still not found) try load share page
4. (if share page found) => share page
5. (if share page not found) => 404
6. (if workspace found) => workspace page
This commit is contained in:
EYHN
2024-08-16 09:12:18 +00:00
parent 620d20710a
commit 83716c2fd9
39 changed files with 431 additions and 267 deletions
@@ -1,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, [