feat!: affine cloud support (#3813)

Co-authored-by: Hongtao Lye <codert.sn@gmail.com>
Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: X1a0t <405028157@qq.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
Alex Yang
2023-08-29 05:07:05 -05:00
committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
414 changed files with 19469 additions and 7591 deletions
@@ -0,0 +1,10 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
export function useCurrenLoginStatus():
| 'authenticated'
| 'unauthenticated'
| 'loading' {
const session = useSession();
return session.status;
}
@@ -0,0 +1,45 @@
import type { DefaultSession } from 'next-auth';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
export type CheckedUser = {
id: string;
name: string;
email: string;
image: string;
hasPassword: boolean;
update: ReturnType<typeof useSession>['update'];
};
// FIXME: Should this namespace be here?
declare module 'next-auth' {
interface Session {
user: {
id: string;
hasPassword: boolean;
} & DefaultSession['user'];
}
}
/**
* This hook checks if the user is logged in.
* If not, it will throw an error.
*/
export function useCurrentUser(): CheckedUser {
const { data: session, status, update } = useSession();
// If you are seeing this error, it means that you are not logged in.
// This should be prohibited in the development environment, please re-write your component logic.
if (status === 'unauthenticated') {
throw new Error('session.status should be authenticated');
}
const user = session?.user;
return {
id: user?.id ?? 'REPLACE_ME_DEFAULT_ID',
name: user?.name ?? 'REPLACE_ME_DEFAULT_NAME',
email: user?.email ?? 'REPLACE_ME_DEFAULT_EMAIL',
image: user?.image ?? 'REPLACE_ME_DEFAULT_URL',
hasPassword: user?.hasPassword ?? false,
update,
};
}
@@ -0,0 +1,30 @@
import type { Permission } from '@affine/graphql';
import { inviteByEmailMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useInviteMember(workspaceId: string) {
const { trigger, isMutating } = useMutation({
mutation: inviteByEmailMutation,
});
const mutate = useMutateCloud();
return {
invite: useCallback(
async (email: string, permission: Permission, sendInviteMail = false) => {
const res = await trigger({
workspaceId,
email,
permission,
sendInviteMail,
});
await mutate();
// return is successful
return res?.invite;
},
[mutate, trigger, workspaceId]
),
isMutating,
};
}
@@ -0,0 +1,57 @@
import {
getWorkspaceSharedPagesQuery,
revokePageMutation,
sharePageMutation,
} from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { useCallback, useMemo } from 'react';
export function useIsSharedPage(
workspaceId: string,
pageId: string
): [isSharedPage: boolean, setSharedPage: (enable: boolean) => void] {
const { data, mutate } = useQuery({
query: getWorkspaceSharedPagesQuery,
variables: {
workspaceId,
},
});
const { trigger: enableSharePage } = useMutation({
mutation: sharePageMutation,
});
const { trigger: disableSharePage } = useMutation({
mutation: revokePageMutation,
});
return [
useMemo(
() => data.workspace.sharedPages.some(id => id === pageId),
[data.workspace.sharedPages, pageId]
),
useCallback(
(enable: boolean) => {
// todo: push notification
if (enable) {
enableSharePage({
workspaceId,
pageId,
})
.then(() => {
return mutate();
})
.catch(console.error);
} else {
disableSharePage({
workspaceId,
pageId,
})
.then(() => {
return mutate();
})
.catch(console.error);
}
mutate().catch(console.error);
},
[disableSharePage, enableSharePage, mutate, pageId, workspaceId]
),
];
}
@@ -0,0 +1,13 @@
import { getIsOwnerQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export function useIsWorkspaceOwner(workspaceId: string) {
const { data } = useQuery({
query: getIsOwnerQuery,
variables: {
workspaceId,
},
});
return data.isOwner;
}
@@ -0,0 +1,23 @@
import { leaveWorkspaceMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useAppHelper } from '../use-workspaces';
export function useLeaveWorkspace() {
const { deleteWorkspaceMeta } = useAppHelper();
const { trigger: leaveWorkspace } = useMutation({
mutation: leaveWorkspaceMutation,
});
return useCallback(
async (workspaceId: string) => {
deleteWorkspaceMeta(workspaceId);
await leaveWorkspace({
workspaceId,
});
},
[deleteWorkspaceMeta, leaveWorkspace]
);
}
+19
View File
@@ -0,0 +1,19 @@
import {
type GetMembersByWorkspaceIdQuery,
getMembersByWorkspaceIdQuery,
} from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export type Member = Omit<
GetMembersByWorkspaceIdQuery['workspace']['members'][number],
'__typename'
>;
export function useMembers(workspaceId: string) {
const { data } = useQuery({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
},
});
return data.workspace.members;
}
@@ -0,0 +1,14 @@
import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
export function useMutateCloud() {
const { mutate } = useSWRConfig();
return useCallback(async () => {
return mutate(key => {
if (Array.isArray(key)) {
return key[0] === 'cloud';
}
return false;
});
}, [mutate]);
}
@@ -0,0 +1,23 @@
import { revokeMemberPermissionMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useRevokeMemberPermission(workspaceId: string) {
const mutate = useMutateCloud();
const { trigger } = useMutation({
mutation: revokeMemberPermissionMutation,
});
return useCallback(
async (userId: string) => {
await trigger({
workspaceId,
userId,
});
await mutate();
},
[mutate, trigger, workspaceId]
);
}
@@ -0,0 +1,15 @@
'use client';
import { useMemo } from 'react';
export function useShareLink(workspaceId: string): string {
return useMemo(() => {
if (environment.isServer) {
throw new Error('useShareLink is not available on server side');
}
if (environment.isDesktop) {
return '???';
} else {
return origin + '/share/' + workspaceId;
}
}, [workspaceId]);
}
@@ -0,0 +1,22 @@
import { setWorkspacePublicByIdMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useToggleCloudPublic(workspaceId: string) {
const mutate = useMutateCloud();
const { trigger } = useMutation({
mutation: setWorkspacePublicByIdMutation,
});
return useCallback(
async (isPublic: boolean) => {
await trigger({
id: workspaceId,
public: isPublic,
});
await mutate();
},
[mutate, trigger, workspaceId]
);
}
@@ -1,34 +1,62 @@
import type { WorkspaceRegistry } from '@affine/env/workspace';
import type { WorkspaceFlavour } from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace';
import {
rootWorkspacesMetadataAtom,
workspaceAdaptersAtom,
} from '@affine/workspace/atom';
import { currentPageIdAtom } from '@toeverything/infra/atom';
import { useSetAtom } from 'jotai';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useTransformWorkspace } from '../use-transform-workspace';
import { openSettingModalAtom } from '../../atoms';
import { useNavigateHelper } from '../use-navigate-helper';
export function useOnTransformWorkspace() {
const transformWorkspace = useTransformWorkspace();
const setWorkspaceId = useSetAtom(currentPageIdAtom);
const setSettingModal = useSetAtom(openSettingModalAtom);
const WorkspaceAdapters = useAtomValue(workspaceAdaptersAtom);
const setMetadata = useSetAtom(rootWorkspacesMetadataAtom);
const { openPage } = useNavigateHelper();
const currentPageId = useAtomValue(currentPageIdAtom);
return useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<void> => {
const workspaceId = await transformWorkspace(from, to, workspace);
// create first, then delete, in case of failure
const newId = await WorkspaceAdapters[to].CRUD.create(
workspace.blockSuiteWorkspace
);
await WorkspaceAdapters[from].CRUD.delete(workspace.blockSuiteWorkspace);
setMetadata(workspaces => {
const idx = workspaces.findIndex(ws => ws.id === workspace.id);
workspaces.splice(idx, 1, {
id: newId,
flavour: to,
version: WorkspaceVersion.SubDoc,
});
return [...workspaces];
}, newId);
// fixme(himself65): setting modal could still open and open the non-exist workspace
setSettingModal(settings => ({
...settings,
open: false,
}));
window.dispatchEvent(
new CustomEvent('affine-workspace:transform', {
detail: {
from,
to,
oldId: workspace.id,
newId: workspaceId,
newId: newId,
},
})
);
setWorkspaceId(workspaceId);
openPage(newId, currentPageId ?? WorkspaceSubPath.ALL);
},
[setWorkspaceId, transformWorkspace]
[WorkspaceAdapters, setMetadata, setSettingModal, openPage, currentPageId]
);
}
+28 -2
View File
@@ -1,7 +1,11 @@
import type { WorkspaceSubPath } from '@affine/env/workspace';
import { useCallback } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation, useNavigate } from 'react-router-dom';
import {
type NavigateOptions,
useLocation,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
export enum RouteLogic {
REPLACE = 'replace',
@@ -78,6 +82,26 @@ export function useNavigateHelper() {
},
[navigate]
);
const jumpToExpired = useCallback(
(logic: RouteLogic = RouteLogic.PUSH) => {
return navigate('/expired', {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToSignIn = useCallback(
(
logic: RouteLogic = RouteLogic.PUSH,
otherOptions?: Omit<NavigateOptions, 'replace'>
) => {
return navigate('/signIn', {
replace: logic === RouteLogic.REPLACE,
...otherOptions,
});
},
[navigate]
);
return {
jumpToPage,
@@ -86,5 +110,7 @@ export function useNavigateHelper() {
jumpToIndex,
jumpTo404,
openPage,
jumpToExpired,
jumpToSignIn,
};
}
@@ -1,41 +0,0 @@
import type { WorkspaceFlavour } from '@affine/env/workspace';
import type { WorkspaceRegistry } from '@affine/env/workspace';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { WorkspaceAdapters } from '../adapters/workspace';
/**
* Transform workspace from one flavor to another
*
* The logic here is to delete the old workspace and create a new one.
*/
export function useTransformWorkspace() {
const set = useSetAtom(rootWorkspacesMetadataAtom);
return useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<string> => {
// create first, then delete, in case of failure
const newId = await WorkspaceAdapters[to].CRUD.create(
workspace.blockSuiteWorkspace
);
await WorkspaceAdapters[from].CRUD.delete(workspace as any);
set(workspaces => {
const idx = workspaces.findIndex(ws => ws.id === workspace.id);
workspaces.splice(idx, 1, {
id: newId,
flavour: to,
version: WorkspaceVersion.DatabaseV3,
});
return [...workspaces];
});
return newId;
},
[set]
);
}
+2 -3
View File
@@ -1,3 +1,4 @@
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
@@ -5,8 +6,6 @@ import { useStaticBlockSuiteWorkspace } from '@toeverything/infra/__internal__/r
import type { Atom } from 'jotai';
import { atom, useAtomValue } from 'jotai';
import type { AffineOfficialWorkspace } from '../shared';
const workspaceWeakMap = new WeakMap<
Workspace,
Atom<Promise<AffineOfficialWorkspace>>
@@ -18,7 +17,7 @@ export function useWorkspace(workspaceId: string): AffineOfficialWorkspace {
const baseAtom = atom(async get => {
const metadata = await get(rootWorkspacesMetadataAtom);
const flavour = metadata.find(({ id }) => id === workspaceId)?.flavour;
assertExists(flavour);
assertExists(flavour, 'workspace flavour not found');
return {
id: workspaceId,
flavour,
+21
View File
@@ -43,6 +43,21 @@ export function useAppHelper() {
},
[set]
),
addCloudWorkspace: useCallback(
(workspaceId: string) => {
getOrCreateWorkspace(workspaceId, WorkspaceFlavour.AFFINE_CLOUD);
set(workspaces => [
...workspaces,
{
id: workspaceId,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
version: WorkspaceVersion.DatabaseV3,
},
]);
logger.debug('imported cloud workspace', workspaceId);
},
[set]
),
createLocalWorkspace: useCallback(
async (name: string): Promise<string> => {
const blockSuiteWorkspace = getOrCreateWorkspace(
@@ -97,5 +112,11 @@ export function useAppHelper() {
},
[jotaiWorkspaces, set]
),
deleteWorkspaceMeta: useCallback(
(workspaceId: string) => {
set(workspaces => workspaces.filter(ws => ws.id !== workspaceId));
},
[set]
),
};
}