feat(infra): framework

This commit is contained in:
EYHN
2024-04-17 14:12:29 +08:00
parent ab17a05df3
commit 06fda3b62c
467 changed files with 9996 additions and 8697 deletions
@@ -1,146 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { uploadAvatarMutation } from '@affine/graphql';
import { render } from '@testing-library/react';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMutation } from '../use-mutation';
import { useQuery } from '../use-query';
let fetch: Mock;
describe('GraphQL wrapper for SWR', () => {
beforeEach(() => {
fetch = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ data: { hello: 1 } }), {
headers: {
'content-type': 'application/json',
},
})
)
);
vi.stubGlobal('fetch', fetch);
});
afterEach(() => {
fetch.mockReset();
});
describe('useQuery', () => {
const Component = ({ id }: { id: number }) => {
const { data, isLoading, error } = useQuery({
query: {
id: 'query',
query: `
query {
hello
}
`,
operationName: 'query',
definitionName: 'query',
},
// @ts-expect-error forgive the fake variables
variables: { id },
});
if (isLoading) {
return <div>loading</div>;
}
if (error) {
return <div>error</div>;
}
// @ts-expect-error
return <div>number: {data!.hello}</div>;
};
it('should send query correctly', async () => {
const component = <Component id={1} />;
const renderer = render(component);
const el = await renderer.findByText('number: 1');
expect(el).toMatchInlineSnapshot(`
<div>
number:${' '}
1
</div>
`);
});
it('should not send request if cache hit', async () => {
const component = <Component id={2} />;
const renderer = render(component);
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(fetch).toBeCalledTimes(1);
render(<Component id={3} />);
expect(fetch).toBeCalledTimes(2);
});
});
describe('useMutation', () => {
const Component = () => {
const { trigger, error, isMutating } = useMutation({
mutation: {
id: 'mutation',
query: `
mutation {
hello
}
`,
operationName: 'mutation',
definitionName: 'mutation',
},
});
if (isMutating) {
return <div>mutating</div>;
}
if (error) {
return <div>error</div>;
}
return (
<div>
<button onClick={() => trigger()}>click</button>
</div>
);
};
it('should trigger mutation', async () => {
const component = <Component />;
const renderer = render(component);
const button = await renderer.findByText('click');
button.click();
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(renderer.asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div>
mutating
</div>
</DocumentFragment>
`);
});
it('should get rid of generated types', async () => {
function _NotActuallyRunDefinedForTypeTesting() {
const { trigger } = useMutation({
mutation: uploadAvatarMutation,
});
trigger({
avatar: new File([''], 'avatar.png'),
});
}
expect(_NotActuallyRunDefinedForTypeTesting).toBeTypeOf('function');
});
});
});
@@ -5,8 +5,12 @@ import 'fake-indexeddb/auto';
import { configureTestingEnvironment } from '@affine/core/testing';
import { renderHook } from '@testing-library/react';
import type { Workspace } from '@toeverything/infra';
import { initEmptyPage, ServiceProviderContext } from '@toeverything/infra';
import type { FrameworkProvider, Workspace } from '@toeverything/infra';
import {
FrameworkRoot,
FrameworkScope,
initEmptyPage,
} from '@toeverything/infra';
import type { PropsWithChildren } from 'react';
import { beforeEach, describe, expect, test, vi } from 'vitest';
@@ -14,33 +18,33 @@ import { useBlockSuiteDocMeta } from '../use-block-suite-page-meta';
import { useDocCollectionHelper } from '../use-block-suite-workspace-helper';
const configureTestingWorkspace = async () => {
const { workspace } = await configureTestingEnvironment();
const { framework, workspace } = await configureTestingEnvironment();
const docCollection = workspace.docCollection;
initEmptyPage(docCollection.createDoc({ id: 'page1' }));
initEmptyPage(docCollection.createDoc({ id: 'page2' }));
return workspace;
return { framework, workspace };
};
beforeEach(async () => {
vi.useFakeTimers({ toFake: ['requestIdleCallback'] });
});
const getWrapper = (workspace: Workspace) =>
const getWrapper = (framework: FrameworkProvider, workspace: Workspace) =>
function Provider({ children }: PropsWithChildren) {
return (
<ServiceProviderContext.Provider value={workspace.services}>
{children}
</ServiceProviderContext.Provider>
<FrameworkRoot framework={framework}>
<FrameworkScope scope={workspace.scope}>{children}</FrameworkScope>
</FrameworkRoot>
);
};
describe('useDocCollectionHelper', () => {
test('should create page', async () => {
const workspace = await configureTestingWorkspace();
const { framework, workspace } = await configureTestingWorkspace();
const docCollection = workspace.docCollection;
const Wrapper = getWrapper(workspace);
const Wrapper = getWrapper(framework, workspace);
expect(docCollection.meta.docMetas.length).toBe(3);
const helperHook = renderHook(() => useDocCollectionHelper(docCollection), {
@@ -3,12 +3,13 @@
*/
import 'fake-indexeddb/auto';
import { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace';
import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
import { render } from '@testing-library/react';
import {
ServiceProviderContext,
FrameworkRoot,
FrameworkScope,
useService,
Workspace,
WorkspaceService,
} from '@toeverything/infra';
import { createStore, Provider } from 'jotai';
import { Suspense } from 'react';
@@ -20,8 +21,11 @@ import { useDocCollectionPageTitle } from '../use-block-suite-workspace-page-tit
const store = createStore();
const Component = () => {
const workspace = useService(Workspace);
const title = useDocCollectionPageTitle(workspace.docCollection, 'page0');
const workspaceService = useService(WorkspaceService);
const title = useDocCollectionPageTitle(
workspaceService.workspace.docCollection,
'page0'
);
return <div>title: {title}</div>;
};
@@ -31,42 +35,54 @@ beforeEach(async () => {
describe('useDocCollectionPageTitle', () => {
test('basic', async () => {
const { workspace, page } = await configureTestingEnvironment();
const { framework, workspace, doc } = await configureTestingEnvironment();
const { findByText, rerender } = render(
<ServiceProviderContext.Provider value={page.services}>
<Provider store={store}>
<Suspense fallback="loading">
<Component />
</Suspense>
</Provider>
</ServiceProviderContext.Provider>
<FrameworkRoot framework={framework}>
<FrameworkScope scope={workspace.scope}>
<FrameworkScope scope={doc.scope}>
<Provider store={store}>
<Suspense fallback="loading">
<Component />
</Suspense>
</Provider>
</FrameworkScope>
</FrameworkScope>
</FrameworkRoot>
);
expect(await findByText('title: Untitled')).toBeDefined();
workspace.docCollection.setDocMeta(page.id, { title: '1' });
workspace.docCollection.setDocMeta(doc.id, { title: '1' });
rerender(
<ServiceProviderContext.Provider value={page.services}>
<Provider store={store}>
<Suspense fallback="loading">
<Component />
</Suspense>
</Provider>
</ServiceProviderContext.Provider>
<FrameworkRoot framework={framework}>
<FrameworkScope scope={workspace.scope}>
<FrameworkScope scope={doc.scope}>
<Provider store={store}>
<Suspense fallback="loading">
<Component />
</Suspense>
</Provider>
</FrameworkScope>
</FrameworkScope>
</FrameworkRoot>
);
expect(await findByText('title: 1')).toBeDefined();
});
test('journal', async () => {
const { workspace, page } = await configureTestingEnvironment();
const adapter = workspace.services.get(WorkspacePropertiesAdapter);
adapter.setJournalPageDateString(page.id, '2021-01-01');
const { framework, workspace, doc } = await configureTestingEnvironment();
const adapter = workspace.scope.get(WorkspacePropertiesAdapter);
adapter.setJournalPageDateString(doc.id, '2021-01-01');
const { findByText } = render(
<ServiceProviderContext.Provider value={page.services}>
<Provider store={store}>
<Suspense fallback="loading">
<Component />
</Suspense>
</Provider>
</ServiceProviderContext.Provider>
<FrameworkRoot framework={framework}>
<FrameworkScope scope={workspace.scope}>
<FrameworkScope scope={doc.scope}>
<Provider store={store}>
<Suspense fallback="loading">
<Component />
</Suspense>
</Provider>
</FrameworkScope>
</FrameworkScope>
</FrameworkRoot>
);
expect(await findByText('title: Jan 1, 2021')).toBeDefined();
});
@@ -2,18 +2,26 @@ import { toast } from '@affine/component';
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 { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } from '@affine/core/modules/share-doc';
import { PublicPageMode } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { usePageHelper } from '../../components/blocksuite/block-suite-page-list/utils';
import { usePublicPages } from './use-is-shared-page';
export const useAllPageListConfig = () => {
const currentWorkspace = useService(Workspace);
const { getPublicMode } = usePublicPages(currentWorkspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const shareDocService = useService(ShareDocsService);
const shareDocs = useLiveData(shareDocService.shareDocs.list$);
useEffect(() => {
// TODO: loading & error UI
shareDocService.shareDocs.revalidate();
}, [shareDocService]);
const workspace = currentWorkspace.docCollection;
const pageMetas = useBlockSuiteDocMeta(workspace);
const { isPreferredEdgeless } = usePageHelper(workspace);
@@ -48,7 +56,16 @@ export const useAllPageListConfig = () => {
return {
allPages: pageMetas,
isEdgeless: isPreferredEdgeless,
getPublicMode,
getPublicMode(id) {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
if (mode === PublicPageMode.Edgeless) {
return 'edgeless';
} else if (mode === PublicPageMode.Page) {
return 'page';
} else {
return undefined;
}
},
docCollection: currentWorkspace.docCollection,
getPage: id => pageMap[id],
favoriteRender: page => {
@@ -64,8 +81,8 @@ export const useAllPageListConfig = () => {
}, [
pageMetas,
isPreferredEdgeless,
getPublicMode,
currentWorkspace.docCollection,
shareDocs,
pageMap,
isActive,
onToggleFavoritePage,
@@ -2,7 +2,7 @@ import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { useDocCollectionHelper } from '@affine/core/hooks/use-block-suite-workspace-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { PageRecordList, useService } from '@toeverything/infra';
import { DocsService, useService } from '@toeverything/infra';
import { useCallback } from 'react';
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
@@ -17,7 +17,7 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
const { createDoc } = useDocCollectionHelper(docCollection);
const { openPage } = useNavigateHelper();
const collectionService = useService(CollectionService);
const pageRecordList = useService(PageRecordList);
const pageRecordList = useService(DocsService).list;
// TODO-Doma
// "Remove" may cause ambiguity here. Consider renaming as "moveToTrash".
@@ -85,7 +85,7 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
const duplicate = useAsyncCallback(
async (pageId: string, openPageAfterDuplication: boolean = true) => {
const currentPageMode = pageRecordList.record$(pageId).value?.mode$.value;
const currentPageMode = pageRecordList.doc$(pageId).value?.mode$.value;
const currentPageMeta = getDocMeta(pageId);
const newPage = createDoc();
const currentPage = docCollection.getDoc(pageId);
@@ -109,9 +109,7 @@ export function useBlockSuiteMetaHelper(docCollection: DocCollection) {
const newPageTitle =
currentPageMeta.title.replace(lastDigitRegex, '') + `(${newNumber})`;
pageRecordList
.record$(newPage.id)
.value?.setMode(currentPageMode || 'page');
pageRecordList.doc$(newPage.id).value?.setMode(currentPageMode || 'page');
setDocTitle(newPage.id, newPageTitle);
openPageAfterDuplication && openPage(docCollection.id, newPage.id);
},
@@ -1,56 +0,0 @@
import { allBlobSizesQuery, SubscriptionPlan } from '@affine/graphql';
import { cssVar } from '@toeverything/theme';
import bytes from 'bytes';
import { useMemo } from 'react';
import { useQuery } from '../use-query';
import { useUserQuota } from '../use-quota';
import { useUserSubscription } from '../use-subscription';
/**
* Hook to get currentUser's cloud storage usage information.
*/
export const useCloudStorageUsage = () => {
const { data } = useQuery({
query: allBlobSizesQuery,
});
const quota = useUserQuota();
const [subscription] = useUserSubscription();
const plan = subscription?.plan ?? SubscriptionPlan.Free;
const maxLimit = useMemo(() => {
if (quota) {
return quota.storageQuota;
}
return bytes.parse(plan === SubscriptionPlan.Free ? '10GB' : '100GB');
}, [plan, quota]);
const used = data?.collectAllBlobSizes?.size ?? 0;
const percent = Math.min(
100,
Math.max(0.5, Number(((used / maxLimit) * 100).toFixed(4)))
);
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
const usedText = bytes.format(used);
const maxLimitText = bytes.format(maxLimit);
return {
/** Current subscription plan of logged in user */
plan,
/** Used storage in bytes */
used,
/** Formatted used storage */
usedText,
/** CSS variable name for progress bar color */
color,
/** Percentage of storage used */
percent,
/** Maximum storage limit in bytes */
maxLimit,
/** Formatted maximum storage limit */
maxLimitText,
};
};
@@ -1,6 +0,0 @@
import { useSession } from './use-current-user';
export function useCurrentLoginStatus() {
const session = useSession();
return session.status;
}
@@ -1,178 +0,0 @@
import { DebugLogger } from '@affine/debug';
import { getBaseUrl } from '@affine/graphql';
import { AIProvider } from '@blocksuite/presets';
import { useEffect, useMemo, useReducer } from 'react';
import useSWR from 'swr';
import { SessionFetchErrorRightAfterLoginOrSignUp } from '../../unexpected-application-state/errors';
import { useAsyncCallback } from '../affine-async-hooks';
const logger = new DebugLogger('auth');
interface User {
id: string;
email: string;
name: string;
hasPassword: boolean;
avatarUrl: string | null;
emailVerified: string | null;
}
export interface Session {
user?: User | null;
status: 'authenticated' | 'unauthenticated' | 'loading';
reload: () => Promise<void>;
}
export type CheckedUser = Session['user'] & {
update: (changes?: Partial<User>) => void;
};
export async function getSession(
url: string = getBaseUrl() + '/api/auth/session'
) {
try {
const res = await fetch(url);
if (res.ok) {
return (await res.json()) as { user?: User | null };
}
logger.error('Failed to fetch session', res.statusText);
throw new Error('Failed to fetch session');
} catch (e) {
logger.error('Failed to fetch session', e);
throw new Error('Failed to fetch session');
}
}
export function useSession(): Session {
const {
data,
mutate,
isLoading,
error: _error, // use error here to avoid uncaught error in the console
} = useSWR('session', () => getSession(), {
errorRetryCount: 3,
errorRetryInterval: 500,
shouldRetryOnError: true,
suspense: false,
});
return {
user: data?.user,
status: isLoading
? 'loading'
: data?.user
? 'authenticated'
: 'unauthenticated',
reload: async () => {
return mutate().then(e => {
console.error(e);
});
},
};
}
type UpdateSessionAction =
| {
type: 'update';
payload?: Partial<User>;
}
| {
type: 'fetchError';
payload: null;
};
function updateSessionReducer(prevState: User, action: UpdateSessionAction) {
const { type, payload } = action;
switch (type) {
case 'update':
return { ...prevState, ...payload };
case 'fetchError':
return prevState;
}
}
/**
* This hook checks if the user is logged in.
* If so, the user object will be cached and returned.
* If not, and there is no cache, it will throw an error.
* If network error or API response error, it will use the cached value.
*/
export function useCurrentUser(): CheckedUser {
const session = useSession();
const [user, dispatcher] = useReducer(
updateSessionReducer,
session.user,
firstSession => {
if (!firstSession) {
// barely possible.
// login succeed but the session request failed then.
// also need a error boundary to handle this error.
throw new SessionFetchErrorRightAfterLoginOrSignUp(
'Fetching session failed',
() => {
getSession()
.then(session => {
if (session.user) {
dispatcher({
type: 'update',
payload: session.user,
});
}
})
.catch(err => {
console.error(err);
});
}
);
}
return firstSession;
}
);
const update = useAsyncCallback(
async (changes?: Partial<User>) => {
dispatcher({
type: 'update',
payload: changes,
});
await session.reload();
},
[dispatcher, session]
);
// update user when session reloaded
// maybe lift user state up to global state?
useEffect(() => {
if (session.user) {
const user = session.user;
dispatcher({ type: 'update', payload: user });
// todo: move this to a better place!
AIProvider.provide('userInfo', () => {
return user;
});
} else {
dispatcher({ type: 'fetchError', payload: null });
}
}, [
session.user,
session.user?.id,
session.user?.name,
session.user?.avatarUrl,
]);
return useMemo(
() => ({
...user,
update,
}),
// only list the things will change as deps
// eslint-disable-next-line react-hooks/exhaustive-deps
[user.id, user.avatarUrl, user.name, update]
);
}
@@ -1,13 +1,15 @@
import { AuthService } from '@affine/core/modules/cloud';
import type { DeleteCollectionInfo } from '@affine/env/filter';
import { useLiveData, useService } from '@toeverything/infra';
import { useMemo } from 'react';
import { useSession } from './use-current-user';
export const useDeleteCollectionInfo = () => {
const { user } = useSession();
const authService = useService(AuthService);
const user = useLiveData(authService.session.account$);
return useMemo<DeleteCollectionInfo | null>(
() => (user ? { userName: user.name, userId: user.id } : null),
() => (user ? { userName: user.label, userId: user.id } : null),
[user]
);
};
@@ -1,8 +1,8 @@
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useMemo } from 'react';
export function useDocEngineStatus() {
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const engineState = useLiveData(
workspace.engine.docEngineState$.throttleTime(100)
@@ -1,15 +1,19 @@
import { useConfirmModal } from '@affine/component';
import { authAtom } from '@affine/core/atoms';
import { setOnceSignedInEventAtom } from '@affine/core/atoms/event';
import { AuthService } from '@affine/core/modules/cloud';
import { WorkspaceSubPath } from '@affine/core/shared';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace } from '@toeverything/infra';
import { useService, WorkspaceManager } from '@toeverything/infra';
import {
useLiveData,
useService,
WorkspacesService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useNavigateHelper } from '../use-navigate-helper';
import { useCurrentLoginStatus } from './use-current-login-status';
interface ConfirmEnableCloudOptions {
/**
@@ -26,21 +30,21 @@ type ConfirmEnableArgs = [Workspace, ConfirmEnableCloudOptions | undefined];
export const useEnableCloud = () => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const loginStatus = useLiveData(useService(AuthService).session.status$);
const setAuthAtom = useSetAtom(authAtom);
const setOnceSignedInEvent = useSetAtom(setOnceSignedInEventAtom);
const { openConfirmModal, closeConfirmModal } = useConfirmModal();
const workspaceManager = useService(WorkspaceManager);
const workspacesService = useService(WorkspacesService);
const { openPage } = useNavigateHelper();
const enableCloud = useCallback(
async (ws: Workspace | null, options?: ConfirmEnableCloudOptions) => {
if (!ws) return;
const { id: newId } = await workspaceManager.transformLocalToCloud(ws);
const { id: newId } = await workspacesService.transformLocalToCloud(ws);
openPage(newId, options?.openPageId || WorkspaceSubPath.ALL);
options?.onSuccess?.();
},
[openPage, workspaceManager]
[openPage, workspacesService]
);
const openSignIn = useCallback(
@@ -1,7 +1,7 @@
import { toast } from '@affine/component';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { CollectionService } from '@affine/core/modules/collection';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type {
Active,
@@ -9,7 +9,7 @@ import type {
Over,
UniqueIdentifier,
} from '@dnd-kit/core';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useMemo } from 'react';
import { useDeleteCollectionInfo } from './use-delete-collection-info';
@@ -153,7 +153,7 @@ export type GlobalDragEndIntent = ReturnType<typeof resolveDragEndIntent>;
export const useGlobalDNDHelper = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const favAdapter = useService(FavoriteItemsAdapter);
const workspace = currentWorkspace.docCollection;
const { setTrashModal } = useTrashModalHelper(workspace);
@@ -8,7 +8,7 @@ import {
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons';
import type { PageMode, Workspace } from '@toeverything/infra';
import type { DocMode, Workspace } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useCallback, useMemo } from 'react';
@@ -64,10 +64,10 @@ export function useIsSharedPage(
pageId: string
): {
isSharedPage: boolean;
changeShare: (mode: PageMode) => void;
changeShare: (mode: DocMode) => void;
disableShare: () => void;
currentShareMode: PageMode;
enableShare: (mode: PageMode) => void;
currentShareMode: DocMode;
enableShare: (mode: DocMode) => void;
} {
const t = useAFFiNEI18N();
const { data, mutate } = useQuery({
@@ -90,14 +90,14 @@ export function useIsSharedPage(
);
const isPageShared = !!publicPage;
const currentShareMode: PageMode =
const currentShareMode: DocMode =
publicPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page';
return [isPageShared, currentShareMode];
}, [data?.workspace.publicPages, pageId]);
const enableShare = useCallback(
(mode: PageMode) => {
(mode: DocMode) => {
const publishMode =
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page;
@@ -125,7 +125,7 @@ export function useIsSharedPage(
);
const changeShare = useCallback(
(mode: PageMode) => {
(mode: DocMode) => {
const publishMode =
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page;
@@ -211,7 +211,7 @@ export function usePublicPages(workspace: Workspace) {
const publicPages: {
id: string;
mode: PageMode;
mode: DocMode;
}[] = useMemo(
() =>
maybeData?.workspace.publicPages.map(i => ({
@@ -1,24 +0,0 @@
import { WorkspaceFlavour } from '@affine/env/workspace';
import { getIsOwnerQuery } from '@affine/graphql';
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useQueryImmutable } from '../use-query';
export function useIsWorkspaceOwner(workspaceMetadata: WorkspaceMetadata) {
const { data } = useQueryImmutable(
workspaceMetadata.flavour !== WorkspaceFlavour.LOCAL
? {
query: getIsOwnerQuery,
variables: {
workspaceId: workspaceMetadata.id,
},
}
: undefined
);
if (workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) {
return true;
}
return data.isOwner;
}
@@ -1,17 +1,17 @@
import { toast } from '@affine/component';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { EdgelessIcon, HistoryIcon, PageIcon } from '@blocksuite/icons';
import {
Doc,
DocService,
PreconditionStrategy,
registerAffineCommand,
useLiveData,
useService,
Workspace,
WorkspaceService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
@@ -22,30 +22,30 @@ import { useExportPage } from './use-export-page';
import { useTrashModalHelper } from './use-trash-modal-helper';
export function useRegisterBlocksuiteEditorCommands() {
const page = useService(Doc);
const pageId = page.id;
const mode = useLiveData(page.mode$);
const doc = useService(DocService).doc;
const docId = doc.id;
const mode = useLiveData(doc.mode$);
const t = useAFFiNEI18N();
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const docCollection = workspace.docCollection;
const { getDocMeta } = useDocMetaHelper(docCollection);
const currentPage = docCollection.getDoc(pageId);
const currentPage = docCollection.getDoc(docId);
assertExists(currentPage);
const pageMeta = getDocMeta(pageId);
const pageMeta = getDocMeta(docId);
assertExists(pageMeta);
const favAdapter = useService(FavoriteItemsAdapter);
const favorite = useLiveData(favAdapter.isFavorite$(pageId, 'doc'));
const favorite = useLiveData(favAdapter.isFavorite$(docId, 'doc'));
const trash = pageMeta.trash ?? false;
const setPageHistoryModalState = useSetAtom(pageHistoryModalAtom);
const openHistoryModal = useCallback(() => {
setPageHistoryModalState(() => ({
pageId,
pageId: docId,
open: true,
}));
}, [pageId, setPageHistoryModalState]);
}, [docId, setPageHistoryModalState]);
const { restoreFromTrash, duplicate } =
useBlockSuiteMetaHelper(docCollection);
@@ -54,10 +54,10 @@ export function useRegisterBlocksuiteEditorCommands() {
const onClickDelete = useCallback(() => {
setTrashModal({
open: true,
pageIds: [pageId],
pageIds: [docId],
pageTitles: [pageMeta.title],
});
}, [pageId, pageMeta.title, setTrashModal]);
}, [docId, pageMeta.title, setTrashModal]);
const isCloudWorkspace = workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
@@ -97,7 +97,7 @@ export function useRegisterBlocksuiteEditorCommands() {
? t['com.affine.favoritePageOperation.remove']()
: t['com.affine.favoritePageOperation.add'](),
run() {
favAdapter.toggle(pageId, 'doc');
favAdapter.toggle(docId, 'doc');
toast(
favorite
? t['com.affine.cmdk.affine.editor.remove-from-favourites']()
@@ -121,7 +121,7 @@ export function useRegisterBlocksuiteEditorCommands() {
: t['com.affine.pageMode.page']()
}`,
run() {
page.toggleMode();
doc.toggleMode();
toast(
mode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
@@ -140,7 +140,7 @@ export function useRegisterBlocksuiteEditorCommands() {
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.header.option.duplicate'](),
run() {
duplicate(pageId);
duplicate(docId);
},
})
);
@@ -219,7 +219,7 @@ export function useRegisterBlocksuiteEditorCommands() {
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
label: t['com.affine.cmdk.affine.editor.restore-from-trash'](),
run() {
restoreFromTrash(pageId);
restoreFromTrash(docId);
},
})
);
@@ -246,14 +246,14 @@ export function useRegisterBlocksuiteEditorCommands() {
mode,
onClickDelete,
exportHandler,
pageId,
restoreFromTrash,
t,
trash,
isCloudWorkspace,
openHistoryModal,
duplicate,
page,
favAdapter,
docId,
doc,
]);
}
@@ -1,89 +0,0 @@
import type { ServerConfigQuery, ServerFeature } from '@affine/graphql';
import {
getBaseUrl,
oauthProvidersQuery,
serverConfigQuery,
} from '@affine/graphql';
import type { BareFetcher, Middleware } from 'swr';
import { useQueryImmutable } from '../use-query';
const wrappedFetcher = (fetcher: BareFetcher<any> | null, ...args: any[]) =>
fetcher?.(...args).catch(() => null);
const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => {
return useSWRNext(key, wrappedFetcher.bind(null, fetcher), config);
};
const useServerConfig = () => {
const { data: config, error } = useQueryImmutable(
{ query: serverConfigQuery },
{
use: [errorHandler],
}
);
if (error || !config) {
return null;
}
return config.serverConfig;
};
type LowercaseServerFeature = Lowercase<ServerFeature>;
type ServerFeatureRecord = {
[key in LowercaseServerFeature]: boolean;
};
export const useServerFeatures = (): ServerFeatureRecord => {
const config = useServerConfig();
if (!config) {
return {} as ServerFeatureRecord;
}
return Array.from(new Set(config.features)).reduce((acc, cur) => {
acc[cur.toLowerCase() as LowercaseServerFeature] = true;
return acc;
}, {} as ServerFeatureRecord);
};
export const useOAuthProviders = () => {
const { data, error } = useQueryImmutable(
{ query: oauthProvidersQuery },
{
use: [errorHandler],
}
);
if (error || !data) {
return [];
}
return data.serverConfig.oauthProviders;
};
export const useServerBaseUrl = () => {
const baseUrl = getBaseUrl();
if (!baseUrl) {
if (environment.isDesktop) {
// don't use window.location in electron
return null;
}
const { protocol, hostname, port } = window.location;
return `${protocol}//${hostname}${port ? `:${port}` : ''}`;
}
return baseUrl;
};
export const useCredentialsRequirement = () => {
const config = useServerConfig();
if (!config) {
return {} as ServerConfigQuery['serverConfig']['credentialsRequirement'];
}
return config.credentialsRequirement;
};
@@ -1,24 +0,0 @@
import { FeatureType, getUserFeaturesQuery } from '@affine/graphql';
import type { BareFetcher, Middleware } from 'swr';
import { useQueryImmutable } from '../use-query';
const wrappedFetcher = (fetcher: BareFetcher<any> | null, ...args: any[]) =>
fetcher?.(...args).catch(() => null);
const errorHandler: Middleware = useSWRNext => (key, fetcher, config) => {
return useSWRNext(key, wrappedFetcher.bind(null, fetcher), config);
};
export function useIsEarlyAccess() {
const { data } = useQueryImmutable(
{
query: getUserFeaturesQuery,
},
{
use: [errorHandler],
}
);
return data?.currentUser?.features.includes(FeatureType.EarlyAccess) ?? false;
}
@@ -1,9 +1,8 @@
import type { Workspace } from '@toeverything/infra';
import { useService } from '@toeverything/infra';
import { useDebouncedState } from 'foxact/use-debounced-state';
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { WorkspacePropertiesAdapter } from '../modules/workspace/properties';
import { WorkspacePropertiesAdapter } from '../modules/properties';
function getProxy<T extends object>(obj: T) {
return new Proxy(obj, {});
@@ -38,11 +37,3 @@ export function useCurrentWorkspacePropertiesAdapter() {
const adapter = useService(WorkspacePropertiesAdapter);
return useReactiveAdapter(adapter);
}
export function useWorkspacePropertiesAdapter(workspace: Workspace) {
const adapter = useMemo(
() => new WorkspacePropertiesAdapter(workspace),
[workspace]
);
return useReactiveAdapter(adapter);
}
@@ -1,9 +1,8 @@
import type { WorkspaceSubPath } from '@affine/core/shared';
import { createContext, useCallback, useContext, useMemo } from 'react';
import type { NavigateFunction, NavigateOptions, To } from 'react-router-dom';
import { useLocation } from 'react-router-dom';
import { useCallback, useContext, useMemo } from 'react';
import type { NavigateOptions, To } from 'react-router-dom';
import { router } from '../router';
import { NavigateContext, router } from '../router';
export enum RouteLogic {
REPLACE = 'replace',
@@ -11,17 +10,16 @@ export enum RouteLogic {
}
function defaultNavigate(to: To, option?: { replace?: boolean }) {
router.navigate(to, option).catch(err => {
console.error('Failed to navigate', err);
});
console.log(to, option);
setTimeout(() => {
router.navigate(to, option).catch(err => {
console.error('Failed to navigate', err);
});
}, 100);
}
export const NavigateContext = createContext<NavigateFunction | null>(null);
// todo: add a name -> path helper in the results
export function useNavigateHelper() {
const location = useLocation();
const navigate = useContext(NavigateContext) ?? defaultNavigate;
const jumpToPage = useCallback(
@@ -89,18 +87,6 @@ export function useNavigateHelper() {
},
[navigate]
);
const jumpToPublicWorkspacePage = useCallback(
(
workspaceId: string,
pageId: string,
logic: RouteLogic = RouteLogic.PUSH
) => {
return navigate(`/public-workspace/${workspaceId}/${pageId}`, {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToSubPath = useCallback(
(
workspaceId: string,
@@ -114,19 +100,11 @@ export function useNavigateHelper() {
[navigate]
);
const isPublicWorkspace = useMemo(() => {
return location.pathname.indexOf('/public-workspace') === 0;
}, [location.pathname]);
const openPage = useCallback(
(workspaceId: string, pageId: string) => {
if (isPublicWorkspace) {
return jumpToPublicWorkspacePage(workspaceId, pageId);
} else {
return jumpToPage(workspaceId, pageId);
}
return jumpToPage(workspaceId, pageId);
},
[jumpToPage, jumpToPublicWorkspacePage, isPublicWorkspace]
[jumpToPage]
);
const jumpToIndex = useCallback(
@@ -174,7 +152,6 @@ export function useNavigateHelper() {
() => ({
jumpToPage,
jumpToPageBlock,
jumpToPublicWorkspacePage,
jumpToSubPath,
jumpToIndex,
jumpTo404,
@@ -189,7 +166,6 @@ export function useNavigateHelper() {
[
jumpToPage,
jumpToPageBlock,
jumpToPublicWorkspacePage,
jumpToSubPath,
jumpToIndex,
jumpTo404,
@@ -1,22 +0,0 @@
import { quotaQuery, workspaceQuotaQuery } from '@affine/graphql';
import { useQuery } from './use-query';
export const useUserQuota = () => {
const { data } = useQuery({
query: quotaQuery,
});
return data.currentUser?.quota || null;
};
export const useWorkspaceQuota = (id: string) => {
const { data } = useQuery({
query: workspaceQuotaQuery,
variables: {
id,
},
});
return data.workspace?.quota || null;
};
@@ -1,5 +1,5 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useStore } from 'jotai';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
@@ -21,7 +21,7 @@ export function useRegisterWorkspaceCommands() {
const store = useStore();
const t = useAFFiNEI18N();
const theme = useTheme();
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const languageHelper = useLanguageHelper();
const pageHelper = usePageHelper(currentWorkspace.docCollection);
const navigationHelper = useNavigateHelper();
@@ -1,49 +0,0 @@
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { SubscriptionQuery } from '@affine/graphql';
import { SubscriptionPlan, subscriptionQuery } from '@affine/graphql';
import { useServerFeatures } from './affine/use-server-config';
import { useQuery } from './use-query';
export type Subscription = NonNullable<
NonNullable<SubscriptionQuery['currentUser']>['subscriptions'][number]
>;
export type SubscriptionMutator = (update?: Partial<Subscription>) => void;
const selector = (data: SubscriptionQuery, plan: SubscriptionPlan) =>
(data.currentUser?.subscriptions ?? []).find(p => p.plan === plan);
export const useUserSubscription = (
plan: SubscriptionPlan = SubscriptionPlan.Pro
) => {
const { payment: hasPaymentFeature } = useServerFeatures();
const { data, mutate } = useQuery(
hasPaymentFeature ? { query: subscriptionQuery } : undefined
);
const set: SubscriptionMutator = useAsyncCallback(
async (update?: Partial<Subscription>) => {
await mutate(prev => {
if (!update || !prev?.currentUser?.subscriptions?.length) {
return;
}
return {
currentUser: {
subscriptions: (prev.currentUser?.subscriptions ?? []).map(sub =>
sub.plan !== plan ? sub : { ...sub, ...update }
),
},
};
});
},
[mutate, plan]
);
if (!hasPaymentFeature) {
return [null, () => {}] as const;
}
return [selector(data, plan), set] as const;
};
@@ -1,12 +1,12 @@
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useService, WorkspaceManager } from '@toeverything/infra';
import { useService, WorkspacesService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
export function useWorkspaceBlobObjectUrl(
meta?: WorkspaceMetadata,
blobKey?: string | null
) {
const workspaceManager = useService(WorkspaceManager);
const workspacesService = useService(WorkspacesService);
const [blob, setBlob] = useState<string | undefined>(undefined);
@@ -17,7 +17,7 @@ export function useWorkspaceBlobObjectUrl(
}
let canceled = false;
let objectUrl: string = '';
workspaceManager
workspacesService
.getWorkspaceBlob(meta, blobKey)
.then(blob => {
if (blob && !canceled) {
@@ -33,7 +33,7 @@ export function useWorkspaceBlobObjectUrl(
canceled = true;
URL.revokeObjectURL(objectUrl);
};
}, [meta, blobKey, workspaceManager]);
}, [meta, blobKey, workspacesService]);
return blob;
}
@@ -1,26 +1,29 @@
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useService, WorkspaceManager } from '@toeverything/infra';
import {
useLiveData,
useService,
WorkspacesService,
} from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { useWorkspaceBlobObjectUrl } from './use-workspace-blob';
export function useWorkspaceInfo(meta: WorkspaceMetadata) {
const workspaceManager = useService(WorkspaceManager);
const workspacesService = useService(WorkspacesService);
const [information, setInformation] = useState(
() => workspaceManager.list.getInformation(meta).info
const [profile, setProfile] = useState(() =>
workspacesService.getProfile(meta)
);
useEffect(() => {
const information = workspaceManager.list.getInformation(meta);
const profile = workspacesService.getProfile(meta);
setInformation(information.info);
return information.onUpdated.on(info => {
setInformation(info);
}).dispose;
}, [meta, workspaceManager]);
profile.revalidate();
return information;
setProfile(profile);
}, [meta, workspacesService]);
return useLiveData(profile.profile$);
}
export function useWorkspaceName(meta: WorkspaceMetadata) {
@@ -1,35 +0,0 @@
import { workspaceQuotaQuery } from '@affine/graphql';
import bytes from 'bytes';
import { useCallback } from 'react';
import { useQuery } from './use-query';
export const useWorkspaceQuota = (workspaceId: string) => {
const { data } = useQuery({
query: workspaceQuotaQuery,
variables: {
id: workspaceId,
},
});
const changeToHumanReadable = useCallback((value: string | number) => {
return bytes.format(bytes.parse(value));
}, []);
const quotaData = data.workspace.quota;
const humanReadableUsedSize = changeToHumanReadable(
quotaData.usedSize.toString()
);
return {
blobLimit: quotaData.blobLimit,
storageQuota: quotaData.storageQuota,
usedSize: quotaData.usedSize,
humanReadable: {
name: quotaData.humanReadable.name,
blobLimit: quotaData.humanReadable.blobLimit,
storageQuota: quotaData.humanReadable.storageQuota,
usedSize: humanReadableUsedSize,
},
};
};
@@ -1,36 +0,0 @@
import type { Workspace, WorkspaceStatus } from '@toeverything/infra';
import { useEffect, useState } from 'react';
export function useWorkspaceStatus<
Selector extends ((status: WorkspaceStatus) => any) | undefined | null,
Status = Selector extends (status: WorkspaceStatus) => any
? ReturnType<Selector>
: WorkspaceStatus,
>(workspace?: Workspace | null, selector?: Selector): Status | null {
// avoid re-render when selector is changed
const [cachedSelector] = useState(() => selector);
const [status, setStatus] = useState<Status | null>(() => {
if (!workspace) {
return null;
}
return cachedSelector ? cachedSelector(workspace.status) : workspace.status;
});
useEffect(() => {
if (!workspace) {
setStatus(null);
return;
}
setStatus(
cachedSelector ? cachedSelector(workspace.status) : workspace.status
);
return workspace.onStatusChange.on(status => {
requestAnimationFrame(() => {
setStatus(cachedSelector ? cachedSelector(status) : status);
});
}).dispose;
}, [cachedSelector, workspace]);
return status;
}
@@ -1,12 +1,12 @@
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import { useService, WorkspaceManager } from '@toeverything/infra';
import { useService, WorkspacesService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
/**
* definitely be careful when using this hook, open workspace is a heavy operation
*/
export function useWorkspace(meta?: WorkspaceMetadata | null) {
const workspaceManager = useService(WorkspaceManager);
const workspaceManager = useService(WorkspacesService);
const [workspace, setWorkspace] = useState<Workspace | null>(null);
@@ -15,10 +15,10 @@ export function useWorkspace(meta?: WorkspaceMetadata | null) {
setWorkspace(null); // set to null if meta is null or undefined
return;
}
const ref = workspaceManager.open(meta);
const ref = workspaceManager.open({ metadata: meta });
setWorkspace(ref.workspace);
return () => {
ref.release();
ref.dispose();
};
}, [meta, workspaceManager]);