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
@@ -2,7 +2,7 @@ import type { FC } from 'react';
export interface FallbackProps<T extends Error = Error> {
error: T;
resetError: () => void;
resetError?: () => void;
}
export const ERROR_REFLECT_KEY = Symbol('ERROR_REFLECT_KEY');
@@ -1,22 +1,20 @@
import {
GlobalContextService,
useLiveData,
useService,
WorkspaceListService,
useServices,
} from '@toeverything/infra';
import { useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { CurrentWorkspaceService } from '../../../../modules/workspace/current-workspace';
export interface DumpInfoProps {
error: any;
}
export const DumpInfo = (_props: DumpInfoProps) => {
const { globalContextService } = useServices({ GlobalContextService });
const location = useLocation();
const workspaceList = useService(WorkspaceListService);
const currentWorkspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace$
const currentWorkspaceId = useLiveData(
globalContextService.globalContext.workspaceId.$
);
const path = location.pathname;
const query = useParams();
@@ -24,9 +22,8 @@ export const DumpInfo = (_props: DumpInfoProps) => {
console.info('DumpInfo', {
path,
query,
currentWorkspaceId: currentWorkspace?.id,
workspaceList,
currentWorkspaceId: currentWorkspaceId,
});
}, [path, query, currentWorkspace, workspaceList]);
}, [path, query, currentWorkspaceId]);
return null;
};
@@ -1,10 +1,14 @@
import { notify } from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms';
import { CurrentWorkspaceService } from '@affine/core/modules/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { AiIcon } from '@blocksuite/icons';
import { Doc, useLiveData, useService } from '@toeverything/infra';
import {
DocService,
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useAtomValue } from 'jotai';
import Lottie from 'lottie-react';
@@ -39,17 +43,20 @@ const EdgelessOnboardingAnimation = () => {
export const AIOnboardingEdgeless = ({
onDismiss,
}: BaseAIOnboardingDialogProps) => {
const { workspaceService, docService } = useServices({
WorkspaceService,
DocService,
});
const t = useAFFiNEI18N();
const notifyId = useLiveData(edgelessNotifyId$);
const generalAIOnboardingOpened = useLiveData(showAIOnboardingGeneral$);
const settingModalOpen = useAtomValue(openSettingModalAtom);
const timeoutRef = useRef<ReturnType<typeof setTimeout>>();
const currentWorkspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace$
);
const isCloud = currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const doc = useService(Doc);
const doc = docService.doc;
const mode = useLiveData(doc.mode$);
useEffect(() => {
@@ -1,11 +1,14 @@
import { Button, Modal } from '@affine/component';
import { openSettingModalAtom } from '@affine/core/atoms';
import { useBlurRoot } from '@affine/core/hooks/use-blur-root';
import { CurrentWorkspaceService } from '@affine/core/modules/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import {
useLiveData,
useServices,
WorkspaceService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import type { ReactNode } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -65,14 +68,13 @@ const getPlayList = (t: Translate): Array<PlayListItem> => [
export const AIOnboardingGeneral = ({
onDismiss,
}: BaseAIOnboardingDialogProps) => {
const { workspaceService } = useServices({ WorkspaceService });
const videoWrapperRef = useRef<HTMLDivElement | null>(null);
const prevVideoRef = useRef<HTMLVideoElement | null>(null);
const currentWorkspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace$
);
const isCloud = currentWorkspace?.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const isCloud =
workspaceService.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const t = useAFFiNEI18N();
// const [open, setOpen] = useState(true);
const open = useLiveData(showAIOnboardingGeneral$);
const [index, setIndex] = useState(0);
const list = useMemo(() => getPlayList(t), [t]);
@@ -1,3 +1,4 @@
import { notify } from '@affine/component';
import {
AuthContent,
BackButton,
@@ -6,37 +7,68 @@ import {
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import React, { useCallback } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useState } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
import { useSubscriptionSearch } from './use-subscription';
export const AfterSignInSendEmail = ({
setAuthState,
email,
onSignedIn,
}: AuthPanelProps) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const subscriptionData = useSubscriptionSearch();
const [resendCountDown, setResendCountDown] = useState(60);
useEffect(() => {
const timer = setInterval(() => {
setResendCountDown(c => Math.max(c - 1, 0));
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
const [isSending, setIsSending] = useState(false);
const t = useAFFiNEI18N();
const authService = useService(AuthService);
useEffect(() => {
const timer = setInterval(() => {
authService.session.revalidate();
}, 3000);
return () => {
clearInterval(timer);
};
}, [authService]);
const loginStatus = useLiveData(authService.session.status$);
const [verifyToken, challenge] = useCaptcha();
const { resendCountDown, allowSendEmail, signIn } = useAuth();
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const onResendClick = useAsyncCallback(async () => {
if (verifyToken) {
await signIn(email, verifyToken, challenge);
setIsSending(true);
try {
if (verifyToken) {
setResendCountDown(60);
await authService.sendEmailMagicLink(email, verifyToken, challenge);
}
} catch (err) {
console.error(err);
notify.error({
message: 'Failed to send email, please try again.',
});
}
}, [challenge, email, signIn, verifyToken]);
setIsSending(false);
}, [authService, challenge, email, verifyToken]);
const onSignInWithPasswordClick = useCallback(() => {
setAuthState('signInWithPassword');
@@ -62,12 +94,12 @@ export const AfterSignInSendEmail = ({
</AuthContent>
<div className={style.resendWrapper}>
{allowSendEmail ? (
{resendCountDown <= 0 ? (
<>
<Captcha />
<Button
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
disabled={!verifyToken}
disabled={!verifyToken || isSending}
type="plain"
size="large"
onClick={onResendClick}
@@ -90,23 +122,19 @@ export const AfterSignInSendEmail = ({
<div className={style.authMessage} style={{ marginTop: 20 }}>
{t['com.affine.auth.sign.auth.code.message']()}
{subscriptionData ? null : ( // If with payment, just support email sign in to avoid duplicate redirect to the same stripe url.
<React.Fragment>
&nbsp;
<Trans
i18nKey="com.affine.auth.sign.auth.code.message.password"
components={{
1: (
<span
className="link"
data-testid="sign-in-with-password"
onClick={onSignInWithPasswordClick}
/>
),
}}
/>
</React.Fragment>
)}
&nbsp;
<Trans
i18nKey="com.affine.auth.sign.auth.code.message.password"
components={{
1: (
<span
className="link"
data-testid="sign-in-with-password"
onClick={onSignInWithPasswordClick}
/>
),
}}
/>
</div>
<BackButton onClick={onBackBottomClick} />
@@ -1,3 +1,4 @@
import { notify } from '@affine/component';
import {
AuthContent,
BackButton,
@@ -8,13 +9,13 @@ import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import type { FC } from 'react';
import { useCallback } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { AuthService } from '../../../modules/cloud';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
import { useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
@@ -22,21 +23,52 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
email,
onSignedIn,
}) => {
const [resendCountDown, setResendCountDown] = useState(60);
useEffect(() => {
const timer = setInterval(() => {
setResendCountDown(c => Math.max(c - 1, 0));
}, 1000);
return () => {
clearInterval(timer);
};
}, []);
const [isSending, setIsSending] = useState(false);
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const [verifyToken, challenge] = useCaptcha();
const { resendCountDown, allowSendEmail, signUp } = useAuth();
const authService = useService(AuthService);
const loginStatus = useLiveData(authService.session.status$);
useEffect(() => {
const timeout = setInterval(() => {
// revalidate session to get the latest status
authService.session.revalidate();
}, 3000);
return () => {
clearInterval(timeout);
};
}, [authService]);
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const [verifyToken, challenge] = useCaptcha();
const onResendClick = useAsyncCallback(async () => {
if (verifyToken) {
await signUp(email, verifyToken, challenge);
setIsSending(true);
try {
if (verifyToken) {
await authService.sendEmailMagicLink(email, verifyToken, challenge);
}
setResendCountDown(60);
} catch (err) {
console.error(err);
notify.error({
message: 'Failed to send email, please try again.',
});
}
}, [challenge, email, signUp, verifyToken]);
setIsSending(false);
}, [authService, challenge, email, verifyToken]);
return (
<>
@@ -54,12 +86,12 @@ export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
</AuthContent>
<div className={style.resendWrapper}>
{allowSendEmail ? (
{resendCountDown <= 0 ? (
<>
<Captcha />
<Button
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
disabled={!verifyToken}
disabled={!verifyToken || isSending}
type="plain"
size="large"
onClick={onResendClick}
@@ -5,7 +5,6 @@ import { useCallback, useMemo } from 'react';
import { AfterSignInSendEmail } from './after-sign-in-send-email';
import { AfterSignUpSendEmail } from './after-sign-up-send-email';
import { NoAccess } from './no-access';
import { SendEmail } from './send-email';
import { SignIn } from './sign-in';
import { SignInWithPassword } from './sign-in-with-password';
@@ -17,8 +16,7 @@ export type AuthProps = {
| 'afterSignInSendEmail'
// throw away
| 'signInWithPassword'
| 'sendEmail'
| 'noAccess';
| 'sendEmail';
setAuthState: (state: AuthProps['state']) => void;
setAuthEmail: (state: AuthProps['email']) => void;
setEmailType: (state: AuthProps['emailType']) => void;
@@ -44,7 +42,6 @@ const config: {
afterSignInSendEmail: AfterSignInSendEmail,
signInWithPassword: SignInWithPassword,
sendEmail: SendEmail,
noAccess: NoAccess,
};
export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
@@ -1,54 +0,0 @@
import {
AuthContent,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { NewIcon } from '@blocksuite/icons';
import type { FC } from 'react';
import { useCallback } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
export const NoAccess: FC<AuthPanelProps> = ({ setAuthState, onSignedIn }) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
return (
<>
<ModalHeader
title={t['com.affine.brand.affineCloud']()}
subTitle={t['Early Access Stage']()}
/>
<AuthContent style={{ height: 162 }}>
{t['com.affine.auth.sign.no.access.hint']()}
<a href="https://community.affine.pro/c/insider-general/">
{t['com.affine.auth.sign.no.access.link']()}
</a>
</AuthContent>
<div className={style.accessMessage}>
<NewIcon
style={{
fontSize: 16,
marginRight: 4,
color: 'var(--affine-icon-color)',
}}
/>
{t['com.affine.auth.sign.no.access.wait']()}
</div>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};
@@ -1,15 +1,14 @@
import { notify, Skeleton } from '@affine/component';
import { Button } from '@affine/component/ui/button';
import {
useOAuthProviders,
useServerFeatures,
} from '@affine/core/hooks/affine/use-server-config';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { OAuthProviderType } from '@affine/graphql';
import { GithubIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useCallback, useState } from 'react';
import { useState } from 'react';
import { AuthService, ServerConfigService } from '../../../modules/cloud';
import { mixpanel } from '../../../utils';
import { useAuth } from './use-auth';
const OAuthProviderMap: Record<
OAuthProviderType,
@@ -27,33 +26,43 @@ const OAuthProviderMap: Record<
};
export function OAuth() {
const { oauth } = useServerFeatures();
const serverConfig = useService(ServerConfigService).serverConfig;
const oauth = useLiveData(serverConfig.features$.map(r => r?.oauth));
const oauthProviders = useLiveData(
serverConfig.config$.map(r => r?.oauthProviders)
);
if (!oauth) {
return null;
return (
<>
<br />
<Skeleton height={50} />
</>
);
}
return <OAuthProviders />;
}
function OAuthProviders() {
const providers = useOAuthProviders();
return providers.map(provider => (
return oauthProviders?.map(provider => (
<OAuthProvider key={provider} provider={provider} />
));
}
function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
const { icon } = OAuthProviderMap[provider];
const { oauthSignIn } = useAuth();
const authService = useService(AuthService);
const [isConnecting, setIsConnecting] = useState(false);
const onClick = useCallback(() => {
setIsConnecting(true);
oauthSignIn(provider);
mixpanel.track('OAuth', { provider });
}, [provider, oauthSignIn]);
const onClick = useAsyncCallback(async () => {
try {
setIsConnecting(true);
await authService.signInOauth(provider);
} catch (err) {
console.error(err);
notify.error({ message: 'Failed to sign in, please try again.' });
} finally {
setIsConnecting(false);
mixpanel.track('OAuth', { provider });
}
}, [authService, provider]);
return (
<Button
@@ -6,19 +6,19 @@ import {
ModalHeader,
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useCredentialsRequirement } from '@affine/core/hooks/affine/use-server-config';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import {
type PasswordLimitsFragment,
sendChangeEmailMutation,
sendChangePasswordEmailMutation,
sendSetPasswordEmailMutation,
sendVerifyEmailMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { useMutation } from '../../../hooks/use-mutation';
import { ServerConfigService } from '../../../modules/cloud';
import type { AuthPanelProps } from './index';
const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {
@@ -35,28 +35,6 @@ const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {
return t['com.affine.settings.email.action.verify']();
}
};
const useContent = (
emailType: AuthPanelProps['emailType'],
email: string,
passwordLimits: PasswordLimitsFragment
) => {
const t = useAFFiNEI18N();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.set.password.message']({
min: String(passwordLimits.minLength),
max: String(passwordLimits.maxLength),
});
case 'changePassword':
return t['com.affine.auth.reset.password.message']();
case 'changeEmail':
case 'verifyEmail':
return t['com.affine.auth.verify.email.message']({
email,
});
}
};
const useNotificationHint = (emailType: AuthPanelProps['emailType']) => {
const t = useAFFiNEI18N();
@@ -161,12 +139,15 @@ export const SendEmail = ({
emailType,
}: AuthPanelProps) => {
const t = useAFFiNEI18N();
const { password: passwordLimits } = useCredentialsRequirement();
const serverConfig = useService(ServerConfigService).serverConfig;
const passwordLimits = useLiveData(
serverConfig.credentialsRequirement$.map(r => r?.password)
);
const [hasSentEmail, setHasSentEmail] = useState(false);
const title = useEmailTitle(emailType);
const hint = useNotificationHint(emailType);
const content = useContent(emailType, email, passwordLimits);
const buttonContent = useButtonContent(emailType);
const { loading, sendEmail } = useSendEmail(emailType);
@@ -178,6 +159,27 @@ export const SendEmail = ({
setHasSentEmail(true);
}, [email, hint, sendEmail]);
const onBack = useCallback(() => {
setAuthState('signIn');
}, [setAuthState]);
if (!passwordLimits) {
// TODO: loading & error UI
return null;
}
const content =
emailType === 'setPassword'
? t['com.affine.auth.set.password.message']({
min: String(passwordLimits.minLength),
max: String(passwordLimits.maxLength),
})
: emailType === 'changePassword'
? t['com.affine.auth.reset.password.message']()
: emailType === 'changeEmail' || emailType === 'verifyEmail'
? t['com.affine.auth.verify.email.message']({ email })
: null;
return (
<>
<ModalHeader
@@ -210,11 +212,7 @@ export const SendEmail = ({
>
{hasSentEmail ? t['com.affine.auth.sent']() : buttonContent}
</Button>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
<BackButton onClick={onBack} />
</>
);
};
@@ -1,20 +1,19 @@
import { Wrapper } from '@affine/component';
import { notify, Wrapper } from '@affine/component';
import {
AuthInput,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useSession } from '@affine/core/hooks/affine/use-current-user';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { AuthService } from '@affine/core/modules/cloud';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService } from '@toeverything/infra';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { signInCloud } from '../../../utils/cloud-utils';
import type { AuthPanelProps } from './index';
import * as styles from './style.css';
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
import { useCaptcha } from './use-captcha';
export const SignInWithPassword: FC<AuthPanelProps> = ({
@@ -24,57 +23,49 @@ export const SignInWithPassword: FC<AuthPanelProps> = ({
onSignedIn,
}) => {
const t = useAFFiNEI18N();
const { reload } = useSession();
const authService = useService(AuthService);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState(false);
const {
signIn,
allowSendEmail,
resetCountDown,
isMutating: sendingEmail,
} = useAuth();
const [verifyToken, challenge] = useCaptcha();
const [isLoading, setIsLoading] = useState(false);
const [sendingEmail, setSendingEmail] = useState(false);
const onSignIn = useAsyncCallback(async () => {
if (isLoading) return;
setIsLoading(true);
const res = await signInCloud('credentials', {
email,
password,
}).catch(console.error);
if (res?.ok) {
await reload();
try {
await authService.signInPassword({
email,
password,
});
onSignedIn?.();
} else {
} catch (err) {
console.error(err);
setPasswordError(true);
} finally {
setIsLoading(false);
}
setIsLoading(false);
}, [email, password, isLoading, onSignedIn, reload]);
}, [isLoading, authService, email, password, onSignedIn]);
const sendMagicLink = useAsyncCallback(async () => {
if (allowSendEmail && verifyToken && !sendingEmail) {
const res = await signIn(email, verifyToken, challenge);
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
resetCountDown();
return setAuthState('noAccess');
if (sendingEmail) return;
setSendingEmail(true);
try {
if (verifyToken) {
await authService.sendEmailMagicLink(email, verifyToken, challenge);
setAuthState('afterSignInSendEmail');
}
setAuthState('afterSignInSendEmail');
} catch (err) {
console.error(err);
notify.error({
message: 'Failed to send email, please try again.',
});
// TODO: handle error better
}
}, [
email,
signIn,
allowSendEmail,
sendingEmail,
setAuthState,
verifyToken,
challenge,
resetCountDown,
]);
setSendingEmail(false);
}, [sendingEmail, verifyToken, authService, email, challenge, setAuthState]);
const sendChangePasswordEmail = useCallback(() => {
setEmailType('changePassword');
@@ -1,28 +1,21 @@
import {
AuthInput,
CountDownRender,
ModalHeader,
} from '@affine/component/auth-components';
import { notify } from '@affine/component';
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { GetUserQuery } from '@affine/graphql';
import { findGraphQLError, getUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowDownBigIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { useMutation } from '../../../hooks/use-mutation';
import { AuthService } from '../../../modules/cloud';
import { mixpanel } from '../../../utils';
import { emailRegex } from '../../../utils/email-regex';
import type { AuthPanelProps } from './index';
import { OAuth } from './oauth';
import * as style from './style.css';
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
import { Captcha, useCaptcha } from './use-captcha';
import { useSubscriptionSearch } from './use-subscription';
function validateEmail(email: string) {
return emailRegex.test(email);
@@ -35,100 +28,74 @@ export const SignIn: FC<AuthPanelProps> = ({
onSignedIn,
}) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const authService = useService(AuthService);
const [isMutating, setIsMutating] = useState(false);
const [verifyToken, challenge] = useCaptcha();
const subscriptionData = useSubscriptionSearch();
const {
isMutating: isSigningIn,
resendCountDown,
allowSendEmail,
signIn,
signUp,
} = useAuth();
const { trigger: verifyUser, isMutating } = useMutation({
mutation: getUserQuery,
});
const [isValidEmail, setIsValidEmail] = useState(true);
useEffect(() => {
const timeout = setInterval(() => {
// revalidate session to get the latest status
authService.session.revalidate();
}, 3000);
return () => {
clearInterval(timeout);
};
}, [authService]);
const loginStatus = useLiveData(authService.session.status$);
if (loginStatus === 'authenticated') {
onSignedIn?.();
}
const onContinue = useAsyncCallback(async () => {
if (!allowSendEmail) {
return;
}
if (!validateEmail(email)) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
// 0 for no access for internal beta
const user: GetUserQuery['user'] | null | 0 = await verifyUser({ email })
.then(({ user }) => user)
.catch(err => {
if (findGraphQLError(err, e => e.extensions.code === 402)) {
setAuthState('noAccess');
return 0;
} else {
throw err;
}
});
if (user === 0) {
return;
}
setIsMutating(true);
setAuthEmail(email);
try {
const { hasPassword, isExist: isUserExist } =
await authService.checkUserByEmail(email);
if (verifyToken) {
if (user) {
// provider password sign-in if user has by default
// If with payment, onl support email sign in to avoid redirect to affine app
if (user.hasPassword && !subscriptionData) {
setAuthState('signInWithPassword');
if (verifyToken) {
if (isUserExist) {
// provider password sign-in if user has by default
// If with payment, onl support email sign in to avoid redirect to affine app
if (hasPassword) {
setAuthState('signInWithPassword');
} else {
mixpanel.track_forms('SignIn', 'Email', {
email,
});
await authService.sendEmailMagicLink(email, verifyToken, challenge);
setAuthState('afterSignInSendEmail');
}
} else {
mixpanel.track_forms('SignIn', 'Email', {
await authService.sendEmailMagicLink(email, verifyToken, challenge);
mixpanel.track_forms('SignUp', 'Email', {
email,
});
const res = await signIn(email, verifyToken, challenge);
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
return setAuthState('noAccess');
}
// TODO, should always get id from user
if ('id' in user) {
mixpanel.identify(user.id);
}
setAuthState('afterSignInSendEmail');
setAuthState('afterSignUpSendEmail');
}
} else {
const res = await signUp(email, verifyToken, challenge);
mixpanel.track_forms('SignUp', 'Email', {
email,
});
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
return setAuthState('noAccess');
} else if (!res || res.status >= 400) {
return;
}
setAuthState('afterSignUpSendEmail');
}
} catch (err) {
console.error(err);
// TODO: better error handling
notify.error({
message: 'Failed to send email. Please try again.',
});
}
}, [
allowSendEmail,
subscriptionData,
challenge,
email,
setAuthEmail,
setAuthState,
signIn,
signUp,
verifyToken,
verifyUser,
]);
setIsMutating(false);
}, [authService, challenge, email, setAuthEmail, setAuthState, verifyToken]);
return (
<>
@@ -164,24 +131,16 @@ export const SignIn: FC<AuthPanelProps> = ({
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating || isSigningIn}
disabled={!allowSendEmail}
loading={isMutating}
icon={
allowSendEmail || isMutating ? (
<ArrowDownBigIcon
width={20}
height={20}
style={{
transform: 'rotate(-90deg)',
color: 'var(--affine-blue)',
}}
/>
) : (
<CountDownRender
className={style.resendCountdownInButton}
timeLeft={resendCountDown}
/>
)
<ArrowDownBigIcon
width={20}
height={20}
style={{
transform: 'rotate(-90deg)',
color: 'var(--affine-blue)',
}}
/>
}
iconPosition="end"
onClick={onContinue}
@@ -1,159 +0,0 @@
import { SignUpPage } from '@affine/component/auth-components';
import { Button } from '@affine/component/ui/button';
import { Loading } from '@affine/component/ui/loading';
import { AffineShapeIcon } from '@affine/core/components/page-list';
import { useCredentialsRequirement } from '@affine/core/hooks/affine/use-server-config';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { popupWindow } from '@affine/core/utils';
import { SubscriptionPlan, type SubscriptionRecurring } from '@affine/graphql';
import {
changePasswordMutation,
createCheckoutSessionMutation,
subscriptionQuery,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { nanoid } from 'nanoid';
import { Suspense, useCallback, useEffect, useMemo } from 'react';
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
import { useMutation } from '../../../hooks/use-mutation';
import {
RouteLogic,
useNavigateHelper,
} from '../../../hooks/use-navigate-helper';
import { useQuery } from '../../../hooks/use-query';
import * as styles from './subscription-redirect.css';
import { useSubscriptionSearch } from './use-subscription';
const usePaymentRedirect = () => {
const searchData = useSubscriptionSearch();
if (!searchData?.recurring) {
throw new Error('Invalid recurring data.');
}
const recurring = searchData.recurring as SubscriptionRecurring;
const plan = searchData.plan as SubscriptionPlan;
const coupon = searchData.coupon;
const idempotencyKey = useMemo(() => nanoid(), []);
const { trigger: checkoutSubscription } = useMutation({
mutation: createCheckoutSessionMutation,
});
return useAsyncCallback(async () => {
const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({
input: {
recurring,
plan,
coupon,
idempotencyKey,
successCallbackLink: null,
},
});
popupWindow(checkoutUrl);
}, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]);
};
const CenterLoading = () => {
return (
<div className={styles.loadingContainer}>
<Loading size={40} />
</div>
);
};
const SubscriptionExisting = () => {
const t = useAFFiNEI18N();
const { jumpToIndex } = useNavigateHelper();
const onButtonClick = useCallback(() => {
jumpToIndex(RouteLogic.REPLACE);
}, [jumpToIndex]);
return (
<div className={styles.subscriptionLayout}>
<div className={styles.subscriptionBox}>
<AffineShapeIcon width={180} height={180} />
<p className={styles.subscriptionTips}>
{t['com.affine.payment.subscription.exist']()}
</p>
<Button
data-testid="upgrade-workspace-button"
onClick={onButtonClick}
size="extraLarge"
type="primary"
>
{t['com.affine.auth.open.affine']()}
</Button>
</div>
</div>
);
};
const SubscriptionRedirection = ({ redirect }: { redirect: () => void }) => {
useEffect(() => {
const timeoutId = setTimeout(() => {
redirect();
}, 100);
return () => {
clearTimeout(timeoutId);
};
}, [redirect]);
return <CenterLoading />;
};
const SubscriptionRedirectWithData = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const searchData = useSubscriptionSearch();
const openPaymentUrl = usePaymentRedirect();
const { password: passwordLimits } = useCredentialsRequirement();
const { trigger: changePassword } = useMutation({
mutation: changePasswordMutation,
});
const { data: subscriptionData } = useQuery({
query: subscriptionQuery,
});
const onSetPassword = useCallback(
async (password: string) => {
await changePassword({
token: searchData?.passwordToken ?? '',
newPassword: password,
});
},
[changePassword, searchData]
);
if (searchData?.withSignUp) {
return (
<SignUpPage
user={user}
passwordLimits={passwordLimits}
onSetPassword={onSetPassword}
onOpenAffine={openPaymentUrl}
openButtonText={t['com.affine.payment.subscription.go-to-subscribe']()}
/>
);
}
if (
subscriptionData.currentUser?.subscriptions?.some(
sub => sub.plan === SubscriptionPlan.Pro
)
) {
return <SubscriptionExisting />;
}
return <SubscriptionRedirection redirect={openPaymentUrl} />;
};
export const SubscriptionRedirect = () => {
return (
<Suspense fallback={<CenterLoading />}>
<SubscriptionRedirectWithData />
</Suspense>
);
};
@@ -1,145 +0,0 @@
import { notify } from '@affine/component';
import type { OAuthProviderType } from '@affine/graphql';
import { atom, useAtom, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { signInCloud } from '../../../utils/cloud-utils';
import { useSubscriptionSearch } from './use-subscription';
const COUNT_DOWN_TIME = 60;
export const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
type AuthStoreAtom = {
allowSendEmail: boolean;
resendCountDown: number;
isMutating: boolean;
};
export const authStoreAtom = atom<AuthStoreAtom>({
isMutating: false,
allowSendEmail: true,
resendCountDown: COUNT_DOWN_TIME,
});
const countDownAtom = atom(
null, // it's a convention to pass `null` for the first argument
(get, set) => {
const clearId = window.setInterval(() => {
const countDown = get(authStoreAtom).resendCountDown;
if (countDown === 0) {
set(authStoreAtom, {
isMutating: false,
allowSendEmail: true,
resendCountDown: COUNT_DOWN_TIME,
});
window.clearInterval(clearId);
return;
}
set(authStoreAtom, {
isMutating: false,
resendCountDown: countDown - 1,
allowSendEmail: false,
});
}, 1000);
}
);
export const useAuth = () => {
const subscriptionData = useSubscriptionSearch();
const [authStore, setAuthStore] = useAtom(authStoreAtom);
const startResendCountDown = useSetAtom(countDownAtom);
const sendEmailMagicLink = useCallback(
async (
signUp: boolean,
email: string,
verifyToken: string,
challenge?: string
) => {
setAuthStore(prev => {
return {
...prev,
isMutating: true,
};
});
const res = await signInCloud(
'email',
{
email,
},
{
...(challenge
? {
challenge,
token: verifyToken,
}
: { token: verifyToken }),
callbackUrl: subscriptionData
? subscriptionData.getRedirectUrl(signUp)
: '/auth/signIn',
}
).catch(console.error);
if (!res?.ok) {
// TODO: i18n
notify.error({
title: 'Send email error',
message: 'Please back to home and try again',
});
}
setAuthStore({
isMutating: false,
allowSendEmail: false,
resendCountDown: COUNT_DOWN_TIME,
});
// TODO: when errored, should reset the count down
startResendCountDown();
return res;
},
[setAuthStore, startResendCountDown, subscriptionData]
);
const signUp = useCallback(
async (email: string, verifyToken: string, challenge?: string) => {
return sendEmailMagicLink(true, email, verifyToken, challenge).catch(
console.error
);
},
[sendEmailMagicLink]
);
const signIn = useCallback(
async (email: string, verifyToken: string, challenge?: string) => {
return sendEmailMagicLink(false, email, verifyToken, challenge).catch(
console.error
);
},
[sendEmailMagicLink]
);
const oauthSignIn = useCallback((provider: OAuthProviderType) => {
signInCloud(provider).catch(console.error);
}, []);
const resetCountDown = useCallback(() => {
setAuthStore({
isMutating: false,
allowSendEmail: false,
resendCountDown: 0,
});
}, [setAuthStore]);
return {
allowSendEmail: authStore.allowSendEmail,
resendCountDown: authStore.resendCountDown,
resetCountDown,
isMutating: authStore.isMutating,
signUp,
signIn,
oauthSignIn,
};
};
@@ -1,5 +1,4 @@
import { apis } from '@affine/electron-api';
import { fetchWithTraceReport } from '@affine/graphql';
import { Turnstile } from '@marsidev/react-turnstile';
import { atom, useAtom, useSetAtom } from 'jotai';
import { useEffect, useRef } from 'react';
@@ -17,7 +16,7 @@ const challengeFetcher = async (url: string) => {
return undefined;
}
const res = await fetchWithTraceReport(url);
const res = await fetch(url);
if (!res.ok) {
throw new Error('Failed to fetch challenge');
}
@@ -1,53 +0,0 @@
import { useMemo } from 'react';
import { useSearchParams } from 'react-router-dom';
enum SubscriptionKey {
Recurring = 'subscription_recurring',
Plan = 'subscription_plan',
Coupon = 'coupon',
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
Token = 'token', // When signup, there should have a token to set password
}
export function useSubscriptionSearch() {
const [searchParams] = useSearchParams();
return useMemo(() => {
const withPayment =
searchParams.has(SubscriptionKey.Recurring) &&
searchParams.has(SubscriptionKey.Plan);
if (!withPayment) {
return null;
}
const recurring = searchParams.get(SubscriptionKey.Recurring);
const plan = searchParams.get(SubscriptionKey.Plan);
const coupon = searchParams.get(SubscriptionKey.Coupon);
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
const passwordToken = searchParams.get(SubscriptionKey.Token);
return {
recurring,
plan,
coupon,
withSignUp,
passwordToken,
getRedirectUrl(signUp?: boolean) {
const paymentParams = new URLSearchParams([
[SubscriptionKey.Recurring, recurring ?? ''],
[SubscriptionKey.Plan, plan ?? ''],
]);
if (coupon) {
paymentParams.set(SubscriptionKey.Coupon, coupon);
}
if (signUp) {
paymentParams.set(SubscriptionKey.SignUp, '1');
}
return `/auth/subscription-redirect?${paymentParams.toString()}`;
},
};
}, [searchParams]);
}
@@ -1,17 +1,36 @@
import { Tooltip } from '@affine/component/ui/tooltip';
import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useServices } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { withErrorBoundary } from 'react-error-boundary';
import { useCallback, useEffect } from 'react';
import { openSettingModalAtom } from '../../../atoms';
import { useUserSubscription } from '../../../hooks/use-subscription';
import {
ServerConfigService,
SubscriptionService,
} from '../../../modules/cloud';
import * as styles from './style.css';
const UserPlanButtonWithData = () => {
const [subscription] = useUserSubscription();
const plan = subscription?.plan ?? SubscriptionPlan.Free;
export const UserPlanButton = () => {
const { serverConfigService, subscriptionService } = useServices({
ServerConfigService,
SubscriptionService,
});
const hasPayment = useLiveData(
serverConfigService.serverConfig.features$.map(r => r?.payment)
);
const plan = useLiveData(
subscriptionService.subscription.primary$.map(subscription =>
subscription !== null ? subscription?.plan : null
)
);
const isLoading = plan === null;
useEffect(() => {
// revalidate subscription to get the latest status
subscriptionService.subscription.revalidate();
}, [subscriptionService]);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const handleClick = useCallback(
@@ -27,9 +46,19 @@ const UserPlanButtonWithData = () => {
const t = useAFFiNEI18N();
if (plan === SubscriptionPlan.SelfHosted) {
// Self hosted version doesn't have a payment apis.
return <div className={styles.userPlanButton}>{plan}</div>;
if (!hasPayment) {
// no payment feature
return;
}
if (isLoading) {
// loading, do nothing
return;
}
if (!plan) {
// no plan, do nothing
return;
}
return (
@@ -40,8 +69,3 @@ const UserPlanButtonWithData = () => {
</Tooltip>
);
};
// If fetch user data failed, just render empty.
export const UserPlanButton = withErrorBoundary(UserPlanButtonWithData, {
fallbackRender: () => null,
});
@@ -1,22 +1,19 @@
import { useLiveData, useService } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { Suspense, useEffect } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { useSession } from '../../../hooks/affine/use-current-user';
import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace';
import { AuthService } from '../../../modules/cloud';
const SyncAwarenessInnerLoggedIn = () => {
const { user } = useSession();
const currentWorkspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace$
);
const authService = useService(AuthService);
const account = useLiveData(authService.session.account$);
const currentWorkspace = useService(WorkspaceService).workspace;
useEffect(() => {
if (user && currentWorkspace) {
if (account && currentWorkspace) {
currentWorkspace.docCollection.awarenessStore.awareness.setLocalStateField(
'user',
{
name: user.name,
name: account.label,
// todo: add avatar?
}
);
@@ -29,13 +26,14 @@ const SyncAwarenessInnerLoggedIn = () => {
};
}
return;
}, [user, currentWorkspace]);
}, [currentWorkspace, account]);
return null;
};
const SyncAwarenessInner = () => {
const loginStatus = useCurrentLoginStatus();
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
if (loginStatus === 'authenticated') {
return <SyncAwarenessInnerLoggedIn />;
@@ -2,23 +2,24 @@ import { Avatar, Input, Switch, toast } from '@affine/component';
import type { ConfirmModalProps } from '@affine/component/ui/modal';
import { ConfirmModal, Modal } from '@affine/component/ui/modal';
import { authAtom, openDisableCloudAlertModalAtom } from '@affine/core/atoms';
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { DebugLogger } from '@affine/debug';
import { apis } from '@affine/electron-api';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { _addLocalWorkspace } from '@affine/workspace-impl';
import {
initEmptyPage,
useLiveData,
useService,
WorkspaceManager,
WorkspacesService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import type { KeyboardEvent } from 'react';
import { useCallback, useLayoutEffect, useState } from 'react';
import { buildShowcaseWorkspace } from '../../../bootstrap/first-app-data';
import { AuthService } from '../../../modules/cloud';
import { _addLocalWorkspace } from '../../../modules/workspace-engine';
import { mixpanel } from '../../../utils';
import { CloudSvg } from '../share-page-modal/cloud-svg';
import * as styles from './index.css';
@@ -47,8 +48,7 @@ interface NameWorkspaceContentProps extends ConfirmModalProps {
) => void;
}
const shouldEnableCloud =
!runtimeConfig.allowLocalWorkspace && !environment.isDesktop;
const shouldEnableCloud = !runtimeConfig.allowLocalWorkspace;
const NameWorkspaceContent = ({
loading,
@@ -58,7 +58,9 @@ const NameWorkspaceContent = ({
const t = useAFFiNEI18N();
const [workspaceName, setWorkspaceName] = useState('');
const [enable, setEnable] = useState(shouldEnableCloud);
const loginStatus = useCurrentLoginStatus();
const session = useService(AuthService).session;
const loginStatus = useLiveData(session.status$);
const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom);
const setOpenSignIn = useSetAtom(authAtom);
@@ -181,7 +183,7 @@ export const CreateWorkspaceModal = ({
}: ModalProps) => {
const [step, setStep] = useState<CreateWorkspaceStep>();
const t = useAFFiNEI18N();
const workspaceManager = useService(WorkspaceManager);
const workspacesService = useService(WorkspacesService);
const [loading, setLoading] = useState(false);
// todo: maybe refactor using xstate?
@@ -202,9 +204,7 @@ export const CreateWorkspaceModal = ({
const result = await apis.dialog.loadDBFile();
if (result.workspaceId && !canceled) {
_addLocalWorkspace(result.workspaceId);
workspaceManager.list.revalidate().catch(err => {
logger.error("can't revalidate workspace list", err);
});
workspacesService.list.revalidate();
onCreate(result.workspaceId);
} else if (result.error || result.canceled) {
if (result.error) {
@@ -223,7 +223,7 @@ export const CreateWorkspaceModal = ({
return () => {
canceled = true;
};
}, [mode, onClose, onCreate, t, workspaceManager]);
}, [mode, onClose, onCreate, t, workspacesService]);
const onConfirmName = useAsyncCallback(
async (name: string, workspaceFlavour: WorkspaceFlavour) => {
@@ -237,13 +237,13 @@ export const CreateWorkspaceModal = ({
// fix me later
if (runtimeConfig.enablePreloading) {
const { id } = await buildShowcaseWorkspace(
workspaceManager,
workspacesService,
workspaceFlavour,
name
);
onCreate(id);
} else {
const { id } = await workspaceManager.createWorkspace(
const { id } = await workspacesService.create(
workspaceFlavour,
async workspace => {
workspace.meta.setName(name);
@@ -259,7 +259,7 @@ export const CreateWorkspaceModal = ({
setLoading(false);
},
[loading, onCreate, workspaceManager]
[loading, onCreate, workspacesService]
);
const onOpenChange = useCallback(
@@ -5,7 +5,7 @@ import {
} from '@affine/core/atoms';
import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useAtom, useSetAtom } from 'jotai';
import { useCallback } from 'react';
@@ -13,7 +13,7 @@ import TopSvg from './top-svg';
export const HistoryTipsModal = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const [open, setOpen] = useAtom(openHistoryTipsModalAtom);
const setTempDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom);
const confirmEnableCloud = useEnableCloud();
@@ -3,12 +3,7 @@ import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspa
import { timestampToLocalDate } from '@affine/core/utils';
import { DebugLogger } from '@affine/debug';
import type { ListHistoryQuery } from '@affine/graphql';
import {
fetchWithTraceReport,
listHistoryQuery,
recoverDocMutation,
} from '@affine/graphql';
import { AffineCloudBlobStorage } from '@affine/workspace-impl';
import { listHistoryQuery, recoverDocMutation } from '@affine/graphql';
import { assertEquals } from '@blocksuite/global/utils';
import { DocCollection } from '@blocksuite/store';
import { globalBlockSuiteSchema } from '@toeverything/infra';
@@ -22,6 +17,7 @@ import {
useMutation,
} from '../../../hooks/use-mutation';
import { useQueryInfinite } from '../../../hooks/use-query';
import { CloudBlobStorage } from '../../../modules/workspace-engine/impls/engine/blob-cloud';
const logger = new DebugLogger('page-history');
@@ -76,11 +72,8 @@ const snapshotFetcher = async (
if (!ts) {
return null;
}
const res = await fetchWithTraceReport(
`/api/workspaces/${workspaceId}/docs/${pageDocId}/histories/${ts}`,
{
priority: 'high',
}
const res = await fetch(
`/api/workspaces/${workspaceId}/docs/${pageDocId}/histories/${ts}`
);
if (!res.ok) {
@@ -104,7 +97,7 @@ const docCollectionMap = new Map<string, DocCollection>();
const getOrCreateShellWorkspace = (workspaceId: string) => {
let docCollection = docCollectionMap.get(workspaceId);
if (!docCollection) {
const blobStorage = new AffineCloudBlobStorage(workspaceId);
const blobStorage = new CloudBlobStorage(workspaceId);
docCollection = new DocCollection({
id: workspaceId,
blobStorages: [
@@ -3,23 +3,29 @@ import { EditorLoading } from '@affine/component/page-detail-skeleton';
import { Button, IconButton } from '@affine/component/ui/button';
import { Modal, useConfirmModal } from '@affine/component/ui/modal';
import { openSettingModalAtom } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, ToggleCollapseIcon } from '@blocksuite/icons';
import type { Doc as BlockSuiteDoc, DocCollection } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
import type { DialogContentProps } from '@radix-ui/react-dialog';
import type { PageMode } from '@toeverything/infra';
import { Doc, useService, Workspace } from '@toeverything/infra';
import {
type DocMode,
DocService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { atom, useAtom, useSetAtom } from 'jotai';
import type { PropsWithChildren } from 'react';
import {
Fragment,
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useState,
@@ -90,8 +96,8 @@ interface HistoryEditorPreviewProps {
ts?: string;
historyList: HistoryList;
snapshotPage?: BlockSuiteDoc;
mode: PageMode;
onModeChange: (mode: PageMode) => void;
mode: DocMode;
onModeChange: (mode: DocMode) => void;
title: string;
}
@@ -190,12 +196,22 @@ const HistoryEditorPreview = ({
const planPromptClosedAtom = atom(false);
const PlanPrompt = () => {
const workspace = useService(Workspace);
const workspaceQuota = useWorkspaceQuota(workspace.id);
const workspaceQuotaService = useService(WorkspaceQuotaService);
useEffect(() => {
workspaceQuotaService.quota.revalidate();
}, [workspaceQuotaService]);
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
const isProWorkspace = useMemo(() => {
return workspaceQuota?.humanReadable.name.toLowerCase() !== 'free';
return workspaceQuota
? workspaceQuota.humanReadable.name.toLowerCase() !== 'free'
: null;
}, [workspaceQuota]);
const isOwner = useIsWorkspaceOwner(workspace.meta);
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
useEffect(() => {
// revalidate permission
permissionService.permission.revalidate();
}, [permissionService]);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const [planPromptClosed, setPlanPromptClosed] = useAtom(planPromptClosedAtom);
@@ -216,11 +232,17 @@ const PlanPrompt = () => {
const planTitle = useMemo(() => {
return (
<div className={styles.planPromptTitle}>
{!isProWorkspace
? t[
'com.affine.history.confirm-restore-modal.plan-prompt.limited-title'
]()
: t['com.affine.history.confirm-restore-modal.plan-prompt.title']()}
{
isProWorkspace === null
? !isProWorkspace
? t[
'com.affine.history.confirm-restore-modal.plan-prompt.limited-title'
]()
: t[
'com.affine.history.confirm-restore-modal.plan-prompt.title'
]()
: '' /* TODO: loading UI */
}
<IconButton
size="small"
@@ -434,8 +456,8 @@ const PageHistoryManager = ({
[activeVersion, onClose, onRestore, snapshotPage]
);
const page = useService(Doc);
const [mode, setMode] = useState<PageMode>(page.mode$.value);
const doc = useService(DocService).doc;
const [mode, setMode] = useState<DocMode>(doc.mode$.value);
const title = useDocCollectionPageTitle(docCollection, pageId);
@@ -531,7 +553,7 @@ export const PageHistoryModal = ({
export const GlobalPageHistoryModal = () => {
const [{ open, pageId }, setState] = useAtom(pageHistoryModalAtom);
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const handleOpenChange = useCallback(
(open: boolean) => {
mixpanel.track('Button', {
@@ -1,6 +1,6 @@
import { ConfirmModal } from '@affine/component';
import { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
import { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService } from '@toeverything/infra';
@@ -1,4 +1,4 @@
import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
import { PagePropertyType } from '@affine/core/modules/properties/services/schema';
import * as icons from '@blocksuite/icons';
import type { SVGProps } from 'react';
@@ -6,7 +6,7 @@ import {
MenuSeparator,
Scrollable,
} from '@affine/component';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { KeyboardEventHandler, MouseEventHandler } from 'react';
import { cloneElement, isValidElement, useCallback } from 'react';
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import type { WorkspacePropertiesAdapter } from '@affine/core/modules/workspace';
import type { WorkspacePropertiesAdapter } from '@affine/core/modules/properties';
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
} from '@affine/core/modules/workspace/properties/schema';
import { PagePropertyType } from '@affine/core/modules/workspace/properties/schema';
} from '@affine/core/modules/properties/services/schema';
import { PagePropertyType } from '@affine/core/modules/properties/services/schema';
import { createFractionalIndexingSortableHelper } from '@affine/core/utils';
import { DebugLogger } from '@affine/debug';
import { nanoid } from 'nanoid';
@@ -1,14 +1,12 @@
import { Checkbox, DatePicker, Menu } from '@affine/component';
import { useAllBlockSuiteDocMeta } from '@affine/core/hooks/use-all-block-suite-page-meta';
import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
PagePropertyType,
} from '@affine/core/modules/workspace/properties/schema';
} from '@affine/core/modules/properties/services/schema';
import { timestampToLocalDate } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { Doc, useService, Workspace } from '@toeverything/infra';
import { DocService, useService } from '@toeverything/infra';
import { noop } from 'lodash-es';
import type { ChangeEventHandler } from 'react';
import { useCallback, useContext, useEffect, useState } from 'react';
@@ -169,21 +167,16 @@ export const NumberValue = ({ property }: PropertyRowValueProps) => {
};
export const TagsValue = () => {
const workspace = useService(Workspace);
const page = useService(Doc);
const docCollection = workspace.docCollection;
const pageMetas = useAllBlockSuiteDocMeta(docCollection);
const doc = useService(DocService).doc;
const pageMeta = pageMetas.find(x => x.id === page.id);
assertExists(pageMeta, 'pageMeta should exist');
const t = useAFFiNEI18N();
return (
<TagsInlineEditor
className={styles.propertyRowValueCell}
placeholder={t['com.affine.page-properties.property-value-placeholder']()}
pageId={page.id}
readonly={page.blockSuiteDoc.readonly}
pageId={doc.id}
readonly={doc.blockSuiteDoc.readonly}
/>
);
};
@@ -13,7 +13,7 @@ import type {
PageInfoCustomProperty,
PageInfoCustomPropertyMeta,
PagePropertyType,
} from '@affine/core/modules/workspace/properties/schema';
} from '@affine/core/modules/properties/services/schema';
import { timestampToLocalDate } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
@@ -1,8 +1,8 @@
import type { MenuProps } from '@affine/component';
import { IconButton, Input, Menu, Scrollable } from '@affine/component';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceLegacyProperties } from '@affine/core/modules/properties';
import { DeleteTagConfirmModal, TagService } from '@affine/core/modules/tag';
import { WorkspaceLegacyProperties } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon, MoreHorizontalIcon, TagsIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
@@ -30,9 +30,9 @@ const InlineTagsList = ({
readonly,
children,
}: PropsWithChildren<InlineTagsListProps>) => {
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags$);
const tagIds = useLiveData(tagService.tagIdsByPageId$(pageId));
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
return (
<div className={styles.inlineTagsContainer} data-testid="inline-tags-list">
@@ -71,8 +71,8 @@ export const EditTagMenu = ({
}>) => {
const t = useAFFiNEI18N();
const legacyProperties = useService(WorkspaceLegacyProperties);
const tagService = useService(TagService);
const tag = useLiveData(tagService.tagByTagId$(tagId));
const tagList = useService(TagService).tagList;
const tag = useLiveData(tagList.tagByTagId$(tagId));
const tagColor = useLiveData(tag?.color$);
const tagValue = useLiveData(tag?.value$);
const navigate = useNavigateHelper();
@@ -169,9 +169,9 @@ export const EditTagMenu = ({
export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
const t = useAFFiNEI18N();
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags$);
const tagIds = useLiveData(tagService.tagIdsByPageId$(pageId));
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const [inputValue, setInputValue] = useState('');
const [open, setOpen] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
@@ -192,10 +192,10 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
[setOpen, setSelectedTagIds]
);
const exactMatch = useLiveData(tagService.tagByTagValue$(inputValue));
const exactMatch = useLiveData(tagList.tagByTagValue$(inputValue));
const filteredTags = useLiveData(
inputValue ? tagService.filterTagsByName$(inputValue) : tagService.tags$
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
);
const onInputChange = useCallback(
@@ -228,10 +228,10 @@ export const TagsEditor = ({ pageId, readonly }: TagsEditorProps) => {
return;
}
rotateNextColor();
const newTag = tagService.createTag(name.trim(), nextColor);
const newTag = tagList.createTag(name.trim(), nextColor);
newTag.tag(pageId);
},
[nextColor, pageId, tagService]
[nextColor, pageId, tagList]
);
const onInputKeyDown = useCallback(
@@ -335,8 +335,8 @@ export const TagsInlineEditor = ({
placeholder,
className,
}: TagsInlineEditorProps) => {
const tagService = useService(TagService);
const tagIds = useLiveData(tagService.tagIdsByPageId$(pageId));
const tagList = useService(TagService).tagList;
const tagIds = useLiveData(tagList.tagIdsByPageId$(pageId));
const empty = !tagIds || tagIds.length === 0;
return (
<Menu
@@ -1,10 +1,10 @@
import { ConfirmModal } from '@affine/component/ui/modal';
import { openQuotaModalAtom, openSettingModalAtom } from '@affine/core/atoms';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useUserQuota } from '@affine/core/hooks/use-quota';
import { useWorkspaceQuota } from '@affine/core/hooks/use-workspace-quota';
import { UserQuotaService } from '@affine/core/modules/cloud';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService, Workspace } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import bytes from 'bytes';
import { useAtom, useSetAtom } from 'jotai';
import { useCallback, useEffect, useMemo } from 'react';
@@ -13,53 +13,74 @@ import { mixpanel } from '../../../utils';
export const CloudQuotaModal = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const [open, setOpen] = useAtom(openQuotaModalAtom);
const workspaceQuota = useWorkspaceQuota(currentWorkspace.id);
const isOwner = useIsWorkspaceOwner(currentWorkspace.meta);
const userQuota = useUserQuota();
const workspaceQuotaService = useService(WorkspaceQuotaService);
useEffect(() => {
workspaceQuotaService.quota.revalidate();
}, [workspaceQuotaService]);
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
useEffect(() => {
// revalidate permission
permissionService.permission.revalidate();
}, [permissionService]);
const quotaService = useService(UserQuotaService);
const userQuota = useLiveData(
quotaService.quota.quota$.map(q =>
q
? {
name: q.humanReadable.name,
blobLimit: q.humanReadable.blobLimit,
}
: null
)
);
const isFreePlanOwner = useMemo(() => {
return isOwner && userQuota?.humanReadable.name.toLowerCase() === 'free';
}, [isOwner, userQuota?.humanReadable.name]);
return isOwner && userQuota?.name === 'free';
}, [isOwner, userQuota]);
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const handleUpgradeConfirm = useCallback(() => {
if (isFreePlanOwner) {
setSettingModalAtom({
open: true,
activeTab: 'plans',
});
}
setSettingModalAtom({
open: true,
activeTab: 'plans',
});
setOpen(false);
}, [isFreePlanOwner, setOpen, setSettingModalAtom]);
}, [setOpen, setSettingModalAtom]);
const description = useMemo(() => {
if (userQuota && isFreePlanOwner) {
return t['com.affine.payment.blob-limit.description.owner.free']({
planName: userQuota.humanReadable.name,
currentQuota: userQuota.humanReadable.blobLimit,
planName: userQuota.name,
currentQuota: userQuota.blobLimit,
upgradeQuota: '100MB',
});
}
if (isOwner && userQuota?.humanReadable.name.toLowerCase() === 'pro') {
if (isOwner && userQuota && userQuota.name.toLowerCase() === 'pro') {
return t['com.affine.payment.blob-limit.description.owner.pro']({
planName: userQuota.humanReadable.name,
quota: userQuota.humanReadable.blobLimit,
planName: userQuota.name,
quota: userQuota.blobLimit,
});
}
return t['com.affine.payment.blob-limit.description.member']({
quota: workspaceQuota.humanReadable.blobLimit,
});
}, [
isFreePlanOwner,
isOwner,
t,
userQuota,
workspaceQuota.humanReadable.blobLimit,
]);
if (workspaceQuota) {
return t['com.affine.payment.blob-limit.description.member']({
quota: workspaceQuota.humanReadable.blobLimit,
});
} else {
// loading
return null;
}
}, [userQuota, isFreePlanOwner, isOwner, workspaceQuota, t]);
useEffect(() => {
if (!workspaceQuota) {
return;
}
currentWorkspace.engine.blob.singleBlobSizeLimit = bytes.parse(
workspaceQuota.blobLimit.toString()
);
@@ -70,15 +91,15 @@ export const CloudQuotaModal = () => {
return () => {
disposable?.dispose();
};
}, [currentWorkspace.engine.blob, setOpen, workspaceQuota.blobLimit]);
}, [currentWorkspace.engine.blob, setOpen, workspaceQuota]);
useEffect(() => {
if (userQuota?.humanReadable) {
if (userQuota?.name) {
mixpanel.people.set({
plan: userQuota.humanReadable.name,
plan: userQuota.name,
});
}
}, [userQuota]);
}, [userQuota?.name]);
return (
<ConfirmModal
@@ -1,13 +1,13 @@
import { ConfirmModal } from '@affine/component/ui/modal';
import { openQuotaModalAtom } from '@affine/core/atoms';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
export const LocalQuotaModal = () => {
const t = useAFFiNEI18N();
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const [open, setOpen] = useAtom(openQuotaModalAtom);
const onConfirm = useCallback(() => {
@@ -1,28 +1,37 @@
import { Button } from '@affine/component';
import { Button, ErrorMessage, Skeleton } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { openSettingModalAtom } from '@affine/core/atoms';
import { useQuery } from '@affine/core/hooks/use-query';
import { useUserSubscription } from '@affine/core/hooks/use-subscription';
import {
getCopilotQuotaQuery,
pricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
ServerConfigService,
SubscriptionService,
UserQuotaService,
} from '@affine/core/modules/cloud';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useAffineAISubscription } from '../general-setting/plans/ai/use-affine-ai-subscription';
import { AIResume, AISubscribe } from '../general-setting/plans/ai/actions';
import * as styles from './storage-progress.css';
export const AIUsagePanel = () => {
const t = useAFFiNEI18N();
const setOpenSettingModal = useSetAtom(openSettingModalAtom);
const [, mutateSubscription] = useUserSubscription();
const { actionType, Action } = useAffineAISubscription();
const serverConfigService = useService(ServerConfigService);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
);
const subscriptionService = useService(SubscriptionService);
const aiSubscription = useLiveData(subscriptionService.subscription.ai$);
const quotaService = useService(UserQuotaService);
useEffect(() => {
quotaService.quota.revalidate();
}, [quotaService]);
const aiActionLimit = useLiveData(quotaService.quota.aiActionLimit$);
const aiActionUsed = useLiveData(quotaService.quota.aiActionUsed$);
const loading = aiActionLimit === null || aiActionUsed === null;
const loadError = useLiveData(quotaService.quota.error$);
const openAiPricingPlan = useCallback(() => {
setOpenSettingModal({
@@ -32,95 +41,89 @@ export const AIUsagePanel = () => {
});
}, [setOpenSettingModal]);
if (actionType === 'cancel') {
if (loading) {
if (loadError) {
return (
<SettingRow
name={t['com.affine.payment.ai.usage-title']()}
desc={''}
spreadCol={false}
>
{/* TODO: i18n */}
<ErrorMessage>Load error</ErrorMessage>
</SettingRow>
);
}
return (
<SettingRow
desc={t['com.affine.payment.ai.usage-description-purchased']()}
name={t['com.affine.payment.ai.usage-title']()}
desc={''}
spreadCol={false}
>
<Button onClick={openAiPricingPlan}>
{t['com.affine.payment.ai.usage.change-button-label']()}
</Button>
<Skeleton height={42} />
</SettingRow>
);
}
if (actionType === 'resume') {
return (
<SettingRow
desc={t['com.affine.payment.ai.usage-description-purchased']()}
name={t['com.affine.payment.ai.usage-title']()}
>
<Action onSubscriptionUpdate={mutateSubscription} />
</SettingRow>
);
}
return <AIUsagePanelNotSubscripted />;
};
export const AIUsagePanelNotSubscripted = () => {
const t = useAFFiNEI18N();
const [, mutateSubscription] = useUserSubscription();
const { actionType, Action } = useAffineAISubscription();
const {
data: { prices },
} = useQuery({ query: pricesQuery });
const { data: quota } = useQuery({
query: getCopilotQuotaQuery,
});
const { limit: nullableLimit, used = 0 } =
quota.currentUser?.copilot.quota || {};
const limit = nullableLimit || 10;
const percent = Math.min(
100,
Math.max(0.5, Number(((used / limit) * 100).toFixed(4)))
);
const price = prices.find(p => p.plan === SubscriptionPlan.AI);
assertExists(price);
const percent =
aiActionLimit === 'unlimited'
? 0
: Math.min(
100,
Math.max(
0.5,
Number(((aiActionUsed / aiActionLimit) * 100).toFixed(4))
)
);
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
return (
<SettingRow
spreadCol={false}
desc=""
spreadCol={aiSubscription ? true : false}
desc={
aiSubscription
? t['com.affine.payment.ai.usage-description-purchased']()
: ''
}
name={t['com.affine.payment.ai.usage-title']()}
>
<div className={styles.storageProgressContainer}>
<div className={styles.storageProgressWrapper}>
<div className="storage-progress-desc">
<span>{t['com.affine.payment.ai.usage.used-caption']()}</span>
<span>
{t['com.affine.payment.ai.usage.used-detail']({
used: used.toString(),
limit: limit.toString(),
})}
</span>
{aiActionLimit === 'unlimited' ? (
hasPaymentFeature && aiSubscription?.canceledAt ? (
<AIResume />
) : (
<Button onClick={openAiPricingPlan}>
{t['com.affine.payment.ai.usage.change-button-label']()}
</Button>
)
) : (
<div className={styles.storageProgressContainer}>
<div className={styles.storageProgressWrapper}>
<div className="storage-progress-desc">
<span>{t['com.affine.payment.ai.usage.used-caption']()}</span>
<span>
{t['com.affine.payment.ai.usage.used-detail']({
used: aiActionUsed.toString(),
limit: aiActionLimit.toString(),
})}
</span>
</div>
<div className="storage-progress-bar-wrapper">
<div
className={styles.storageProgressBar}
style={{ width: `${percent}%`, backgroundColor: color }}
></div>
</div>
</div>
<div className="storage-progress-bar-wrapper">
<div
className={styles.storageProgressBar}
style={{ width: `${percent}%`, backgroundColor: color }}
></div>
</div>
{hasPaymentFeature && (
<AISubscribe type="primary" className={styles.storageButton}>
{t['com.affine.payment.ai.usage.purchase-button-label']()}
</AISubscribe>
)}
</div>
<Action
recurring={SubscriptionRecurring.Yearly}
onSubscriptionUpdate={mutateSubscription}
price={price}
type="primary"
className={styles.storageButton}
>
{actionType === 'subscribe'
? t['com.affine.payment.ai.usage.purchase-button-label']()
: null}
</Action>
</div>
)}
</SettingRow>
);
};
@@ -5,29 +5,21 @@ import {
} from '@affine/component/setting-components';
import { Avatar } from '@affine/component/ui/avatar';
import { Button } from '@affine/component/ui/button';
import { SWRErrorBoundary } from '@affine/core/components/pure/swr-error-bundary';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import {
removeAvatarMutation,
updateUserProfileMutation,
uploadAvatarMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons';
import { useEnsureLiveData, useService } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import type { FC, MouseEvent } from 'react';
import { Suspense, useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
authAtom,
openSettingModalAtom,
openSignOutModalAtom,
} from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { useServerFeatures } from '../../../../hooks/affine/use-server-config';
import { useMutation } from '../../../../hooks/use-mutation';
import { AuthService } from '../../../../modules/cloud';
import { mixpanel } from '../../../../utils';
import { validateAndReduceImage } from '../../../../utils/reduce-image';
import { Upload } from '../../../pure/file-upload';
import { AIUsagePanel } from './ai-usage-panel';
import { StorageProgress } from './storage-progress';
@@ -35,27 +27,16 @@ import * as styles from './style.css';
export const UserAvatar = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const { trigger: avatarTrigger } = useMutation({
mutation: uploadAvatarMutation,
});
const { trigger: removeAvatarTrigger } = useMutation({
mutation: removeAvatarMutation,
});
const session = useService(AuthService).session;
const account = useEnsureLiveData(session.account$);
const handleUpdateUserAvatar = useAsyncCallback(
async (file: File) => {
try {
mixpanel.track_forms('UpdateProfile', 'UploadAvatar', {
userId: user.id,
userId: account.id,
});
const reducedFile = await validateAndReduceImage(file);
const data = await avatarTrigger({
avatar: reducedFile, // Pass the reducedFile directly to the avatarTrigger
});
user.update({ avatarUrl: data.uploadAvatar.avatarUrl });
// TODO: i18n
await session.uploadAvatar(file);
notify.success({ title: 'Update user avatar success' });
} catch (e) {
// TODO: i18n
@@ -65,19 +46,18 @@ export const UserAvatar = () => {
});
}
},
[avatarTrigger, user]
[account, session]
);
const handleRemoveUserAvatar = useAsyncCallback(
async (e: MouseEvent<HTMLButtonElement>) => {
mixpanel.track('RemoveAvatar', {
userId: user.id,
userId: account.id,
});
e.stopPropagation();
await removeAvatarTrigger();
user.update({ avatarUrl: null });
await session.removeAvatar();
},
[removeAvatarTrigger, user]
[account, session]
);
return (
@@ -88,10 +68,10 @@ export const UserAvatar = () => {
>
<Avatar
size={56}
name={user.name}
url={user.avatarUrl}
name={account.label}
url={account.avatar}
hoverIcon={<CameraIcon />}
onRemove={user.avatarUrl ? handleRemoveUserAvatar : undefined}
onRemove={account.avatar ? handleRemoveUserAvatar : undefined}
avatarTooltipOptions={{ content: t['Click to replace photo']() }}
removeTooltipOptions={{ content: t['Remove photo']() }}
data-testid="user-setting-avatar"
@@ -105,33 +85,31 @@ export const UserAvatar = () => {
export const AvatarAndName = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const [input, setInput] = useState<string>(user.name);
const session = useService(AuthService).session;
const account = useEnsureLiveData(session.account$);
const [input, setInput] = useState<string>(account.label);
const { trigger: updateProfile } = useMutation({
mutation: updateUserProfileMutation,
});
const allowUpdate = !!input && input !== user.name;
const allowUpdate = !!input && input !== account.label;
const handleUpdateUserName = useAsyncCallback(async () => {
if (account === null) {
return;
}
if (!allowUpdate) {
return;
}
try {
mixpanel.track_forms('UpdateProfile', 'UpdateUsername', {
userId: user.id,
userId: account.id,
});
const data = await updateProfile({
input: { name: input },
});
user.update({ name: data.updateProfile.name });
await session.updateLabel(input);
} catch (e) {
notify.error({
title: 'Failed to update user name.',
message: String(e),
});
}
}, [allowUpdate, input, user, updateProfile]);
}, [account, allowUpdate, session, input]);
return (
<SettingRow
@@ -140,9 +118,7 @@ export const AvatarAndName = () => {
spreadCol={false}
>
<FlexWrapper style={{ margin: '12px 0 24px 0' }} alignItems="center">
<Suspense>
<UserAvatar />
</Suspense>
<UserAvatar />
<div className={styles.profileInputWrapper}>
<label>{t['com.affine.settings.profile.name']()}</label>
@@ -178,7 +154,6 @@ export const AvatarAndName = () => {
const StoragePanel = () => {
const t = useAFFiNEI18N();
const { payment: hasPaymentFeature } = useServerFeatures();
const setSettingModalAtom = useSetAtom(openSettingModalAtom);
const onUpgrade = useCallback(() => {
@@ -197,14 +172,18 @@ const StoragePanel = () => {
desc=""
spreadCol={false}
>
<StorageProgress onUpgrade={onUpgrade} upgradable={hasPaymentFeature} />
<StorageProgress onUpgrade={onUpgrade} />
</SettingRow>
);
};
export const AccountSetting: FC = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const session = useService(AuthService).session;
useEffect(() => {
session.revalidate();
}, [session]);
const account = useEnsureLiveData(session.account$);
const setAuthModal = useSetAtom(authAtom);
const setSignOutModal = useSetAtom(openSignOutModalAtom);
@@ -212,19 +191,19 @@ export const AccountSetting: FC = () => {
setAuthModal({
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: user.emailVerified ? 'changeEmail' : 'verifyEmail',
email: account.email,
emailType: account.info?.emailVerified ? 'changeEmail' : 'verifyEmail',
});
}, [setAuthModal, user.email, user.emailVerified]);
}, [account.email, account.info?.emailVerified, setAuthModal]);
const onPasswordButtonClick = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: user.hasPassword ? 'changePassword' : 'setPassword',
email: account.email,
emailType: account.info?.hasPassword ? 'changePassword' : 'setPassword',
});
}, [setAuthModal, user.email, user.hasPassword]);
}, [account.email, account.info?.hasPassword, setAuthModal]);
const onOpenSignOutModal = useCallback(() => {
setSignOutModal(true);
@@ -238,9 +217,9 @@ export const AccountSetting: FC = () => {
data-testid="account-title"
/>
<AvatarAndName />
<SettingRow name={t['com.affine.settings.email']()} desc={user.email}>
<SettingRow name={t['com.affine.settings.email']()} desc={account.email}>
<Button onClick={onChangeEmail} className={styles.button}>
{user.emailVerified
{account.info?.emailVerified
? t['com.affine.settings.email.action.change']()
: t['com.affine.settings.email.action.verify']()}
</Button>
@@ -250,19 +229,13 @@ export const AccountSetting: FC = () => {
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onPasswordButtonClick} className={styles.button}>
{user.hasPassword
{account.info?.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<Suspense>
<StoragePanel />
</Suspense>
<SWRErrorBoundary fallback={<div />}>
<Suspense>
<AIUsagePanel />
</Suspense>
</SWRErrorBoundary>
<StoragePanel />
<AIUsagePanel />
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}
@@ -1,9 +1,15 @@
import { Button, Tooltip } from '@affine/component';
import { useCloudStorageUsage } from '@affine/core/hooks/affine/use-cloud-storage-usage';
import { Button, ErrorMessage, Skeleton, Tooltip } from '@affine/component';
import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMemo } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { useEffect, useMemo } from 'react';
import {
ServerConfigService,
SubscriptionService,
UserQuotaService,
} from '../../../../modules/cloud';
import * as styles from './storage-progress.css';
export interface StorageProgressProgress {
@@ -16,20 +22,55 @@ enum ButtonType {
Default = 'default',
}
export const StorageProgress = ({
upgradable = true,
onUpgrade,
}: StorageProgressProgress) => {
export const StorageProgress = ({ onUpgrade }: StorageProgressProgress) => {
const t = useAFFiNEI18N();
const { plan, usedText, color, percent, maxLimitText } =
useCloudStorageUsage();
const quota = useService(UserQuotaService).quota;
useEffect(() => {
// revalidate quota to get the latest status
quota.revalidate();
}, [quota]);
const color = useLiveData(quota.color$);
const usedFormatted = useLiveData(quota.usedFormatted$);
const maxFormatted = useLiveData(quota.maxFormatted$);
const percent = useLiveData(quota.percent$);
const serverConfigService = useService(ServerConfigService);
const hasPaymentFeature = useLiveData(
serverConfigService.serverConfig.features$.map(f => f?.payment)
);
const subscription = useService(SubscriptionService).subscription;
useEffect(() => {
// revalidate subscription to get the latest status
subscription.revalidate();
}, [subscription]);
const primarySubscription = useLiveData(subscription.primary$);
const isFreeUser =
!primarySubscription || primarySubscription?.plan === SubscriptionPlan.Free;
const quotaName = useLiveData(
quota.quota$.map(q => (q !== null ? q?.humanReadable.name : null))
);
const loading =
primarySubscription === null || percent === null || quotaName === null;
const loadError = useLiveData(quota.error$);
const buttonType = useMemo(() => {
if (plan === SubscriptionPlan.Free) {
if (isFreeUser) {
return ButtonType.Primary;
}
return ButtonType.Default;
}, [plan]);
}, [isFreeUser]);
if (loading) {
if (loadError) {
// TODO: i18n
return <ErrorMessage>Load error</ErrorMessage>;
}
// TODO: loading UI
return <Skeleton height={42} />;
}
return (
<div className={styles.storageProgressContainer}>
@@ -37,24 +78,27 @@ export const StorageProgress = ({
<div className="storage-progress-desc">
<span>{t['com.affine.storage.used.hint']()}</span>
<span>
{usedText}/{maxLimitText}
{` (${plan} ${t['com.affine.storage.plan']()})`}
{usedFormatted}/{maxFormatted}
{` (${quotaName} ${t['com.affine.storage.plan']()})`}
</span>
</div>
<div className="storage-progress-bar-wrapper">
<div
className={styles.storageProgressBar}
style={{ width: `${percent}%`, backgroundColor: color }}
style={{
width: `${percent}%`,
backgroundColor: color ?? cssVar('processingColor'),
}}
></div>
</div>
</div>
{upgradable ? (
{hasPaymentFeature ? (
<Tooltip
options={{ hidden: percent < 100 }}
content={
plan === 'Free'
isFreeUser
? t['com.affine.storage.maximum-tips']()
: t['com.affine.storage.maximum-tips.pro']()
}
@@ -65,7 +109,7 @@ export const StorageProgress = ({
onClick={onUpgrade}
className={styles.storageButton}
>
{plan === 'Free'
{isFreeUser
? t['com.affine.storage.upgrade']()
: t['com.affine.storage.change-plan']()}
</Button>
@@ -14,29 +14,29 @@ import {
getInvoicesCountQuery,
invoicesQuery,
InvoiceStatus,
pricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
SubscriptionStatus,
} from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { useMutation } from '../../../../../hooks/use-mutation';
import { useQuery } from '../../../../../hooks/use-query';
import type { SubscriptionMutator } from '../../../../../hooks/use-subscription';
import { useUserSubscription } from '../../../../../hooks/use-subscription';
import { mixpanel, popupWindow } from '../../../../../utils';
import { SubscriptionService } from '../../../../../modules/cloud';
import {
mixpanel,
popupWindow,
timestampToLocalDate,
} from '../../../../../utils';
import { SWRErrorBoundary } from '../../../../pure/swr-error-bundary';
import { CancelAction, ResumeAction } from '../plans/actions';
import { useAffineAIPrice } from '../plans/ai/use-affine-ai-price';
import { useAffineAISubscription } from '../plans/ai/use-affine-ai-subscription';
import { AICancel, AIResume, AISubscribe } from '../plans/ai/actions';
import * as styles from './style.css';
enum DescriptionI18NKey {
@@ -58,13 +58,8 @@ const getMessageKey = (
};
export const BillingSettings = () => {
const status = useCurrentLoginStatus();
const t = useAFFiNEI18N();
if (status !== 'authenticated') {
return null;
}
return (
<>
<SettingHeader
@@ -94,181 +89,220 @@ export const BillingSettings = () => {
};
const SubscriptionSettings = () => {
const [subscription, mutateSubscription] = useUserSubscription();
const [openCancelModal, setOpenCancelModal] = useState(false);
const {
isFree: isFreeAI,
actionType: aiActionType,
Action: AIAction,
billingTip,
} = useAffineAISubscription();
const { data: pricesQueryResult } = useQuery({
query: pricesQuery,
});
const plan = subscription?.plan ?? SubscriptionPlan.Free;
const recurring = subscription?.recurring ?? SubscriptionRecurring.Monthly;
const price = pricesQueryResult.prices.find(price => price.plan === plan);
const aiPrice = pricesQueryResult.prices.find(
price => price.plan === SubscriptionPlan.AI
);
assertExists(aiPrice);
const amount =
plan === SubscriptionPlan.Free
? '0'
: price
? recurring === SubscriptionRecurring.Monthly
? String((price.amount ?? 0) / 100)
: String((price.yearlyAmount ?? 0) / 100)
: '?';
const { priceReadable: aiPriceReadable, priceFrequency: aiPriceFrequency } =
useAffineAIPrice(aiPrice);
const t = useAFFiNEI18N();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const primarySubscription = useLiveData(
subscriptionService.subscription.primary$
);
const proPrice = useLiveData(subscriptionService.prices.proPrice$);
const currentPlan =
primarySubscription === null
? null
: primarySubscription?.plan ?? SubscriptionPlan.Free;
const [openCancelModal, setOpenCancelModal] = useState(false);
const recurring =
primarySubscription?.recurring ?? SubscriptionRecurring.Monthly;
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
const gotoPlansSetting = useCallback(() => {
mixpanel.track('Button', {
resolve: 'ChangePlan',
currentPlan: plan,
currentPlan: currentPlan,
});
setOpenSettingModalAtom({
open: true,
activeTab: 'plans',
});
}, [setOpenSettingModalAtom, plan]);
}, [currentPlan, setOpenSettingModalAtom]);
const currentPlanDesc = useMemo(() => {
const messageKey = getMessageKey(plan, recurring);
return (
<Trans
i18nKey={messageKey}
values={{
planName: plan,
}}
components={{
1: (
<span
onClick={gotoPlansSetting}
className={styles.currentPlanName}
/>
),
}}
/>
);
}, [plan, recurring, gotoPlansSetting]);
const amount = currentPlan
? currentPlan === SubscriptionPlan.Free
? '0'
: proPrice
? recurring === SubscriptionRecurring.Monthly
? String((proPrice.amount ?? 0) / 100)
: String((proPrice.yearlyAmount ?? 0) / 100)
: '?'
: '?';
return (
<div className={styles.subscription}>
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.current-plan']()}
desc={currentPlanDesc}
/>
<PlanAction plan={plan} gotoPlansSetting={gotoPlansSetting} />
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{recurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
<div className={styles.planCard} style={{ marginTop: 24 }}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.ai-plan']()}
desc={
isFreeAI
? t['com.affine.payment.billing-setting.ai.free-desc']()
: billingTip
}
/>
{aiPrice?.yearlyAmount ? (
<AIAction
className={styles.planAction}
price={aiPrice}
recurring={SubscriptionRecurring.Yearly}
onSubscriptionUpdate={mutateSubscription}
>
{aiActionType === 'subscribe'
? t['com.affine.payment.billing-setting.ai.purchase']()
: null}
</AIAction>
) : null}
</div>
<p className={styles.planPrice}>
{isFreeAI ? '$0' : aiPriceReadable}
<span className={styles.billingFrequency}>/{aiPriceFrequency}</span>
</p>
</div>
{subscription?.status === SubscriptionStatus.Active && (
<>
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<PaymentMethodUpdater />
</SettingRow>
{subscription.nextBillAt && (
{currentPlan !== null ? (
<div className={styles.planCard}>
<div className={styles.currentPlan}>
<SettingRow
name={t['com.affine.payment.billing-setting.renew-date']()}
desc={t[
'com.affine.payment.billing-setting.renew-date.description'
]({
renewDate: new Date(
subscription.nextBillAt
).toLocaleDateString(),
})}
spreadCol={false}
name={t['com.affine.payment.billing-setting.current-plan']()}
desc={
<Trans
i18nKey={getMessageKey(currentPlan, recurring)}
values={{
planName: currentPlan,
}}
components={{
1: (
<span
onClick={gotoPlansSetting}
className={styles.currentPlanName}
/>
),
}}
/>
}
/>
)}
{subscription.canceledAt ? (
<SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t[
'com.affine.payment.billing-setting.expiration-date.description'
]({
expirationDate: new Date(subscription.end).toLocaleDateString(),
})}
>
<ResumeSubscription onSubscriptionUpdate={mutateSubscription} />
</SettingRow>
) : (
<CancelAction
open={openCancelModal}
onOpenChange={setOpenCancelModal}
onSubscriptionUpdate={mutateSubscription}
>
<SettingRow
style={{ cursor: 'pointer' }}
onClick={() => setOpenCancelModal(true)}
className="dangerous-setting"
name={t[
'com.affine.payment.billing-setting.cancel-subscription'
]()}
desc={t[
'com.affine.payment.billing-setting.cancel-subscription.description'
]()}
>
<CancelSubscription />
</SettingRow>
</CancelAction>
)}
</>
<PlanAction
plan={currentPlan}
gotoPlansSetting={gotoPlansSetting}
/>
</div>
<p className={styles.planPrice}>
${amount}
<span className={styles.billingFrequency}>
/
{recurring === SubscriptionRecurring.Monthly
? t['com.affine.payment.billing-setting.month']()
: t['com.affine.payment.billing-setting.year']()}
</span>
</p>
</div>
) : (
<SubscriptionSettingSkeleton />
)}
<AIPlanCard />
{primarySubscription !== null ? (
primarySubscription?.status === SubscriptionStatus.Active && (
<>
<SettingRow
className={styles.paymentMethod}
name={t['com.affine.payment.billing-setting.payment-method']()}
desc={t[
'com.affine.payment.billing-setting.payment-method.description'
]()}
>
<PaymentMethodUpdater />
</SettingRow>
{primarySubscription.nextBillAt && (
<SettingRow
name={t['com.affine.payment.billing-setting.renew-date']()}
desc={t[
'com.affine.payment.billing-setting.renew-date.description'
]({
renewDate: new Date(
primarySubscription.nextBillAt
).toLocaleDateString(),
})}
/>
)}
{primarySubscription.canceledAt ? (
<SettingRow
name={t['com.affine.payment.billing-setting.expiration-date']()}
desc={t[
'com.affine.payment.billing-setting.expiration-date.description'
]({
expirationDate: new Date(
primarySubscription.end
).toLocaleDateString(),
})}
>
<ResumeSubscription />
</SettingRow>
) : (
<CancelAction
open={openCancelModal}
onOpenChange={setOpenCancelModal}
>
<SettingRow
style={{ cursor: 'pointer' }}
onClick={() => setOpenCancelModal(true)}
className="dangerous-setting"
name={t[
'com.affine.payment.billing-setting.cancel-subscription'
]()}
desc={t[
'com.affine.payment.billing-setting.cancel-subscription.description'
]()}
>
<CancelSubscription />
</SettingRow>
</CancelAction>
)}
</>
)
) : (
<SubscriptionSettingSkeleton />
)}
</div>
);
};
const AIPlanCard = () => {
const t = useAFFiNEI18N();
const subscriptionService = useService(SubscriptionService);
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const price = useLiveData(subscriptionService.prices.aiPrice$);
const subscription = useLiveData(subscriptionService.subscription.ai$);
const priceReadable = price?.yearlyAmount
? `$${(price.yearlyAmount / 100).toFixed(2)}`
: '?';
const priceFrequency = t['com.affine.payment.billing-setting.year']();
if (subscription === null) {
return <Skeleton height={100} />;
}
const billingTip =
subscription === undefined
? t['com.affine.payment.billing-setting.ai.free-desc']()
: subscription?.nextBillAt
? t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: timestampToLocalDate(subscription.nextBillAt),
})
: subscription?.canceledAt && subscription.end
? t['com.affine.payment.ai.billing-tip.end-at']({
end: timestampToLocalDate(subscription.end),
})
: null;
return (
<div className={styles.planCard} style={{ marginTop: 24 }}>
<div className={styles.currentPlan}>
<SettingRow
spreadCol={false}
name={t['com.affine.payment.billing-setting.ai-plan']()}
desc={billingTip}
/>
{price?.yearlyAmount ? (
subscription ? (
subscription.canceledAt ? (
<AIResume />
) : (
<AICancel />
)
) : (
<AISubscribe>
{t['com.affine.payment.billing-setting.ai.purchase']()}
</AISubscribe>
)
) : null}
</div>
<p className={styles.planPrice}>
{subscription ? priceReadable : '$0'}
<span className={styles.billingFrequency}>/{priceFrequency}</span>
</p>
</div>
);
};
@@ -322,20 +356,12 @@ const PaymentMethodUpdater = () => {
);
};
const ResumeSubscription = ({
onSubscriptionUpdate,
}: {
onSubscriptionUpdate: SubscriptionMutator;
}) => {
const ResumeSubscription = () => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
return (
<ResumeAction
open={open}
onOpenChange={setOpen}
onSubscriptionUpdate={onSubscriptionUpdate}
>
<ResumeAction open={open} onOpenChange={setOpen}>
<Button className={styles.button} onClick={() => setOpen(true)}>
{t['com.affine.payment.billing-setting.resume-subscription']()}
</Button>
@@ -4,10 +4,10 @@ import {
InformationIcon,
KeyboardIcon,
} from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import type { ReactElement, SVGProps } from 'react';
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
import { useServerFeatures } from '../../../../hooks/affine/use-server-config';
import { AuthService, ServerConfigService } from '../../../../modules/cloud';
import type { GeneralSettingKey } from '../types';
import { AboutAffine } from './about';
import { AppearanceSettings } from './appearance';
@@ -27,8 +27,11 @@ export type GeneralSettingList = GeneralSettingListItem[];
export const useGeneralSettingList = (): GeneralSettingList => {
const t = useAFFiNEI18N();
const status = useCurrentLoginStatus();
const { payment: hasPaymentFeature } = useServerFeatures();
const status = useLiveData(useService(AuthService).session.status$);
const serverConfig = useService(ServerConfigService).serverConfig;
const hasPaymentFeature = useLiveData(
serverConfig.features$.map(f => f?.payment)
);
const settings: GeneralSettingListItem[] = [
{
@@ -1,14 +1,10 @@
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription';
import {
cancelSubscriptionMutation,
resumeSubscriptionMutation,
} from '@affine/graphql';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import type { PropsWithChildren } from 'react';
import { useState } from 'react';
import { useMutation } from '../../../../../hooks/use-mutation';
import { SubscriptionService } from '../../../../../modules/cloud';
import { ConfirmLoadingModal, DowngradeModal } from './modals';
/**
@@ -20,30 +16,27 @@ export const CancelAction = ({
children,
open,
onOpenChange,
onSubscriptionUpdate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubscriptionUpdate: SubscriptionMutator;
} & PropsWithChildren) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { trigger, isMutating } = useMutation({
mutation: cancelSubscriptionMutation,
});
const [isMutating, setIsMutating] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const downgrade = useAsyncCallback(async () => {
await trigger(
{ idempotencyKey },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate(data.cancelSubscription);
onOpenChange(false);
},
}
);
}, [trigger, idempotencyKey, onSubscriptionUpdate, onOpenChange]);
try {
setIsMutating(true);
await subscription.cancelSubscription(idempotencyKey);
subscription.revalidate();
await subscription.isRevalidating$.waitFor(v => !v);
// refresh idempotency key
setIdempotencyKey(nanoid());
onOpenChange(false);
} finally {
setIsMutating(false);
}
}, [subscription, idempotencyKey, onOpenChange]);
return (
<>
@@ -67,31 +60,28 @@ export const ResumeAction = ({
children,
open,
onOpenChange,
onSubscriptionUpdate,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onSubscriptionUpdate: SubscriptionMutator;
} & PropsWithChildren) => {
// allow replay request on network error until component unmount or success
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
const [isMutating, setIsMutating] = useState(false);
const subscription = useService(SubscriptionService).subscription;
const resume = useAsyncCallback(async () => {
await trigger(
{ idempotencyKey },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate(data.resumeSubscription);
onOpenChange(false);
},
}
);
}, [trigger, idempotencyKey, onSubscriptionUpdate, onOpenChange]);
try {
setIsMutating(true);
await subscription.resumeSubscription(idempotencyKey);
subscription.revalidate();
await subscription.isRevalidating$.waitFor(v => !v);
// refresh idempotency key
setIdempotencyKey(nanoid());
onOpenChange(false);
} finally {
setIsMutating(false);
}
}, [subscription, idempotencyKey, onOpenChange]);
return (
<>
@@ -1,23 +1,19 @@
import { Button, type ButtonProps, useConfirmModal } from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/hooks/use-mutation';
import { cancelSubscriptionMutation, SubscriptionPlan } from '@affine/graphql';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useState } from 'react';
import type { BaseActionProps } from '../types';
export interface AICancelProps extends BaseActionProps, ButtonProps {}
export const AICancel = ({
onSubscriptionUpdate,
...btnProps
}: AICancelProps) => {
export interface AICancelProps extends ButtonProps {}
export const AICancel = ({ ...btnProps }: AICancelProps) => {
const t = useAFFiNEI18N();
const [isMutating, setMutating] = useState(false);
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { trigger, isMutating } = useMutation({
mutation: cancelSubscriptionMutation,
});
const subscription = useService(SubscriptionService).subscription;
const { openConfirmModal } = useConfirmModal();
const cancel = useAsyncCallback(async () => {
@@ -37,19 +33,19 @@ export const AICancel = ({
type: 'primary',
},
onConfirm: async () => {
await trigger(
{ idempotencyKey, plan: SubscriptionPlan.AI },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate?.(data.cancelSubscription);
},
}
);
try {
setMutating(true);
await subscription.cancelSubscription(
idempotencyKey,
SubscriptionPlan.AI
);
setIdempotencyKey(nanoid());
} finally {
setMutating(false);
}
},
});
}, [openConfirmModal, t, trigger, idempotencyKey, onSubscriptionUpdate]);
}, [openConfirmModal, t, subscription, idempotencyKey]);
return (
<Button onClick={cancel} loading={isMutating} type="primary" {...btnProps}>
@@ -4,9 +4,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import type { BaseActionProps } from '../types';
export const AILogin = (btnProps: BaseActionProps & ButtonProps) => {
export const AILogin = (btnProps: ButtonProps) => {
const t = useAFFiNEI18N();
const setOpen = useSetAtom(authAtom);
@@ -5,28 +5,24 @@ import {
useConfirmModal,
} from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/hooks/use-mutation';
import { resumeSubscriptionMutation, SubscriptionPlan } from '@affine/graphql';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons';
import { useService } from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { nanoid } from 'nanoid';
import { useState } from 'react';
import type { BaseActionProps } from '../types';
export interface AIResumeProps extends ButtonProps {}
export interface AIResumeProps extends BaseActionProps, ButtonProps {}
export const AIResume = ({
onSubscriptionUpdate,
...btnProps
}: AIResumeProps) => {
export const AIResume = ({ ...btnProps }: AIResumeProps) => {
const t = useAFFiNEI18N();
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const subscription = useService(SubscriptionService).subscription;
const [isMutating, setIsMutating] = useState(false);
const { isMutating, trigger } = useMutation({
mutation: resumeSubscriptionMutation,
});
const { openConfirmModal } = useConfirmModal();
const resume = useAsyncCallback(async () => {
@@ -42,32 +38,24 @@ export const AIResume = ({
cancelText:
t['com.affine.payment.ai.action.resume.confirm.cancel-text'](),
onConfirm: async () => {
await trigger(
{ idempotencyKey, plan: SubscriptionPlan.AI },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate?.(data.resumeSubscription);
notify({
icon: (
<SingleSelectSelectSolidIcon
color={cssVar('processingColor')}
/>
),
title:
t[
'com.affine.payment.ai.action.resume.confirm.notify.title'
](),
message:
t['com.affine.payment.ai.action.resume.confirm.notify.msg'](),
});
},
}
setIsMutating(true);
await subscription.resumeSubscription(
idempotencyKey,
SubscriptionPlan.AI
);
notify({
icon: (
<SingleSelectSelectSolidIcon color={cssVar('processingColor')} />
),
title:
t['com.affine.payment.ai.action.resume.confirm.notify.title'](),
message:
t['com.affine.payment.ai.action.resume.confirm.notify.msg'](),
});
setIdempotencyKey(nanoid());
},
});
}, [openConfirmModal, t, trigger, idempotencyKey, onSubscriptionUpdate]);
}, [openConfirmModal, t, subscription, idempotencyKey]);
return (
<Button loading={isMutating} onClick={resume} type="primary" {...btnProps}>
@@ -1,75 +1,64 @@
import { Button, type ButtonProps } from '@affine/component';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useMutation } from '@affine/core/hooks/use-mutation';
import { SubscriptionService } from '@affine/core/modules/cloud';
import { popupWindow } from '@affine/core/utils';
import {
createCheckoutSessionMutation,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
import { assertExists } from '@blocksuite/global/utils';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import { nanoid } from 'nanoid';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useEffect, useState } from 'react';
import type { BaseActionProps } from '../types';
import { useAffineAIPrice } from '../use-affine-ai-price';
export interface AISubscribeProps extends ButtonProps {}
export interface AISubscribeProps extends BaseActionProps, ButtonProps {}
export const AISubscribe = ({ ...btnProps }: AISubscribeProps) => {
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const [isMutating, setMutating] = useState(false);
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
export const AISubscribe = ({
price,
recurring = SubscriptionRecurring.Yearly,
onSubscriptionUpdate,
...btnProps
}: AISubscribeProps) => {
assertExists(price);
const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]);
const { priceReadable, priceFrequency } = useAffineAIPrice(price);
const subscriptionService = useService(SubscriptionService);
const price = useLiveData(subscriptionService.prices.aiPrice$);
useEffect(() => {
subscriptionService.prices.revalidate();
}, [subscriptionService]);
const newTabRef = useRef<Window | null>(null);
const { isMutating, trigger } = useMutation({
mutation: createCheckoutSessionMutation,
});
const onClose = useCallback(() => {
newTabRef.current = null;
onSubscriptionUpdate?.();
}, [onSubscriptionUpdate]);
const t = useAFFiNEI18N();
useEffect(() => {
return () => {
if (newTabRef.current) {
newTabRef.current.removeEventListener('close', onClose);
newTabRef.current = null;
}
};
}, [onClose]);
if (isOpenedExternalWindow) {
// when the external window is opened, revalidate the subscription status every 3 seconds
const timer = setInterval(() => {
subscriptionService.subscription.revalidate();
}, 3000);
return () => clearInterval(timer);
}
return;
}, [isOpenedExternalWindow, subscriptionService]);
const subscribe = useAsyncCallback(async () => {
await trigger(
{
input: {
recurring,
idempotencyKey,
plan: SubscriptionPlan.AI,
coupon: null,
successCallbackLink: null,
},
},
{
onSuccess: data => {
const newTab = popupWindow(data.createCheckoutSession);
if (newTab) {
newTabRef.current = newTab;
newTab.addEventListener('close', onClose);
}
},
}
);
}, [idempotencyKey, onClose, recurring, trigger]);
setMutating(true);
try {
const session = await subscriptionService.createCheckoutSession({
recurring: SubscriptionRecurring.Yearly,
idempotencyKey,
plan: SubscriptionPlan.AI,
coupon: null,
successCallbackLink: null,
});
popupWindow(session);
setOpenedExternalWindow(true);
setIdempotencyKey(nanoid());
} finally {
setMutating(false);
}
}, [idempotencyKey, subscriptionService]);
if (!price.yearlyAmount) return null;
if (!price || !price.yearlyAmount) {
// TODO: loading UI
return null;
}
const priceReadable = `$${(price.yearlyAmount / 100).toFixed(2)}`;
const priceFrequency = t['com.affine.payment.billing-setting.year']();
return (
<Button
@@ -1,40 +1,45 @@
import { Button } from '@affine/component';
import {
type SubscriptionMutator,
useUserSubscription,
} from '@affine/core/hooks/use-subscription';
import {
type PricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { timestampToLocalDate } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { AIPlanLayout } from '../layout';
import { AICancel, AILogin, AIResume, AISubscribe } from './actions';
import * as styles from './ai-plan.css';
import { AIBenefits } from './benefits';
import type { BaseActionProps } from './types';
import { useAffineAISubscription } from './use-affine-ai-subscription';
interface AIPlanProps {
price?: PricesQuery['prices'][number];
onSubscriptionUpdate: SubscriptionMutator;
}
export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
export const AIPlan = () => {
const t = useAFFiNEI18N();
const recurring = SubscriptionRecurring.Yearly;
const { Action, billingTip } = useAffineAISubscription();
const [subscription] = useUserSubscription(SubscriptionPlan.AI);
const authService = useService(AuthService);
const subscriptionService = useService(SubscriptionService);
const subscription = useLiveData(subscriptionService.subscription.ai$);
const price = useLiveData(subscriptionService.prices.aiPrice$);
const isLoggedIn =
useLiveData(authService.session.status$) === 'authenticated';
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
// yearly subscription should always be available
if (!price?.yearlyAmount) return null;
if (!price?.yearlyAmount || subscription === null) {
// TODO: loading UI
return null;
}
const baseActionProps: BaseActionProps = {
price,
recurring,
onSubscriptionUpdate,
};
const billingTip = subscription?.nextBillAt
? t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: timestampToLocalDate(subscription.nextBillAt),
})
: subscription?.canceledAt && subscription.end
? t['com.affine.payment.ai.billing-tip.end-at']({
end: timestampToLocalDate(subscription.end),
})
: null;
return (
<AIPlanLayout
@@ -60,13 +65,29 @@ export const AIPlan = ({ price, onSubscriptionUpdate }: AIPlanProps) => {
<div className={styles.actionBlock}>
<div className={styles.actionButtons}>
<Action {...baseActionProps} className={styles.purchaseButton} />
{subscription ? null : (
<a href="https://ai.affine.pro" target="_blank" rel="noreferrer">
<Button className={styles.learnAIButton}>
{t['com.affine.payment.ai.pricing-plan.learn']()}
</Button>
</a>
{isLoggedIn ? (
subscription ? (
subscription.canceledAt ? (
<AIResume className={styles.purchaseButton} />
) : (
<AICancel className={styles.purchaseButton} />
)
) : (
<>
<AISubscribe className={styles.learnAIButton} />
<a
href="https://ai.affine.pro"
target="_blank"
rel="noreferrer"
>
<Button className={styles.learnAIButton}>
{t['com.affine.payment.ai.pricing-plan.learn']()}
</Button>
</a>
</>
)
) : (
<AILogin className={styles.purchaseButton} />
)}
</div>
{billingTip ? (
@@ -1,8 +0,0 @@
import type { SubscriptionMutator } from '@affine/core/hooks/use-subscription';
import type { PricesQuery, SubscriptionRecurring } from '@affine/graphql';
export interface BaseActionProps {
price?: PricesQuery['prices'][number];
recurring?: SubscriptionRecurring;
onSubscriptionUpdate?: SubscriptionMutator;
}
@@ -1,14 +0,0 @@
import type { PricesQuery } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
export const useAffineAIPrice = (price: PricesQuery['prices'][number]) => {
const t = useAFFiNEI18N();
assertExists(price.yearlyAmount, 'AFFiNE AI yearly price is missing');
const priceReadable = `$${(price.yearlyAmount / 100).toFixed(2)}`;
const priceFrequency = t['com.affine.payment.billing-setting.year']();
return { priceReadable, priceFrequency };
};
@@ -1,48 +0,0 @@
import { useCurrentLoginStatus } from '@affine/core/hooks/affine/use-current-login-status';
import { useUserSubscription } from '@affine/core/hooks/use-subscription';
import { timestampToLocalDate } from '@affine/core/utils';
import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { AICancel, AILogin, AIResume, AISubscribe } from './actions';
const plan = SubscriptionPlan.AI;
export type ActionType = 'login' | 'subscribe' | 'resume' | 'cancel';
export const useAffineAISubscription = () => {
const t = useAFFiNEI18N();
const loggedIn = useCurrentLoginStatus() === 'authenticated';
const [subscription] = useUserSubscription(plan);
const isCancelled = !!subscription?.canceledAt;
const actionType: ActionType = !loggedIn
? 'login'
: !subscription
? 'subscribe'
: isCancelled
? 'resume'
: 'cancel';
const Action = {
login: AILogin,
subscribe: AISubscribe,
resume: AIResume,
cancel: AICancel,
}[actionType];
const isFree = !subscription;
const billingTip = subscription?.nextBillAt
? t['com.affine.payment.ai.billing-tip.next-bill-at']({
due: timestampToLocalDate(subscription.nextBillAt),
})
: subscription?.canceledAt && subscription.end
? t['com.affine.payment.ai.billing-tip.end-at']({
end: timestampToLocalDate(subscription.end),
})
: null;
return { actionType, Action, billingTip, isFree };
};
@@ -1,20 +1,13 @@
import { notify, Switch } from '@affine/component';
import {
pricesQuery,
SubscriptionPlan,
SubscriptionRecurring,
} from '@affine/graphql';
import { Switch } from '@affine/component';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SingleSelectSelectSolidIcon } from '@blocksuite/icons';
import { cssVar } from '@toeverything/theme';
import { Suspense, useEffect, useMemo, useRef, useState } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo, useRef, useState } from 'react';
import type { FallbackProps } from 'react-error-boundary';
import { SWRErrorBoundary } from '../../../../../components/pure/swr-error-bundary';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { useQuery } from '../../../../../hooks/use-query';
import { useUserSubscription } from '../../../../../hooks/use-subscription';
import { AuthService, SubscriptionService } from '../../../../../modules/cloud';
import { AIPlan } from './ai/ai-plan';
import { type FixedPrice, getPlanDetail } from './cloud-plans';
import { CloudPlanLayout, PlanLayout } from './layout';
@@ -36,19 +29,24 @@ const getRecurringLabel = ({
const Settings = () => {
const t = useAFFiNEI18N();
const [subscription, mutateSubscription] = useUserSubscription();
const loggedIn = useCurrentLoginStatus() === 'authenticated';
const loggedIn =
useLiveData(useService(AuthService).session.status$) === 'authenticated';
const planDetail = useMemo(() => getPlanDetail(t), [t]);
const scrollWrapper = useRef<HTMLDivElement>(null);
const {
data: { prices },
} = useQuery({
query: pricesQuery,
});
const subscriptionService = useService(SubscriptionService);
const primarySubscription = useLiveData(
subscriptionService.subscription.primary$
);
const prices = useLiveData(subscriptionService.prices.prices$);
prices.forEach(price => {
useEffect(() => {
subscriptionService.subscription.revalidate();
subscriptionService.prices.revalidate();
}, [subscriptionService]);
prices?.forEach(price => {
const detail = planDetail.get(price.plan);
if (detail?.type === 'fixed') {
@@ -64,13 +62,13 @@ const Settings = () => {
});
const [recurring, setRecurring] = useState<SubscriptionRecurring>(
subscription?.recurring ?? SubscriptionRecurring.Yearly
primarySubscription?.recurring ?? SubscriptionRecurring.Yearly
);
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const isCanceled = !!subscription?.canceledAt;
const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free;
const isCanceled = !!primarySubscription?.canceledAt;
const currentRecurring =
subscription?.recurring ?? SubscriptionRecurring.Monthly;
primarySubscription?.recurring ?? SubscriptionRecurring.Monthly;
const yearlyDiscount = (
planDetail.get(SubscriptionPlan.Pro) as FixedPrice | undefined
@@ -176,33 +174,7 @@ const Settings = () => {
const cloudScroll = (
<div className={styles.planCardsWrapper} ref={scrollWrapper}>
{Array.from(planDetail.values()).map(detail => {
return (
<PlanCard
key={detail.plan}
onSubscriptionUpdate={mutateSubscription}
onNotify={({ detail, recurring }) => {
notify({
style: 'normal',
icon: (
<SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />
),
title: t['com.affine.payment.updated-notify-title'](),
message:
detail.plan === SubscriptionPlan.Free
? t[
'com.affine.payment.updated-notify-msg.cancel-subscription'
]()
: t['com.affine.payment.updated-notify-msg']({
plan: getRecurringLabel({
recurring: recurring as SubscriptionRecurring,
t,
}),
}),
});
}}
{...{ detail, subscription, recurring }}
/>
);
return <PlanCard key={detail.plan} {...{ detail, recurring }} />;
})}
</div>
);
@@ -214,6 +186,10 @@ const Settings = () => {
</div>
);
if (prices === null) {
return <PlansSkeleton />;
}
return (
<PlanLayout
cloud={
@@ -225,12 +201,7 @@ const Settings = () => {
scrollRef={scrollWrapper}
/>
}
ai={
<AIPlan
price={prices.find(p => p.plan === SubscriptionPlan.AI)}
onSubscriptionUpdate={mutateSubscription}
/>
}
ai={<AIPlan />}
/>
);
};
@@ -238,9 +209,7 @@ const Settings = () => {
export const AFFiNEPricingPlans = () => {
return (
<SWRErrorBoundary FallbackComponent={PlansErrorBoundary}>
<Suspense fallback={<PlansSkeleton />}>
<Settings />
</Suspense>
<Settings />
</SWRErrorBoundary>
);
};
@@ -1,30 +1,21 @@
import { Button } from '@affine/component/ui/button';
import { Tooltip } from '@affine/component/ui/tooltip';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import type {
Subscription,
SubscriptionMutator,
} from '@affine/core/hooks/use-subscription';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { popupWindow } from '@affine/core/utils';
import type { SubscriptionRecurring } from '@affine/graphql';
import {
createCheckoutSessionMutation,
SubscriptionPlan,
SubscriptionStatus,
updateSubscriptionMutation,
} from '@affine/graphql';
import { SubscriptionPlan, SubscriptionStatus } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DoneIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import { useAtom, useSetAtom } from 'jotai';
import { nanoid } from 'nanoid';
import type { PropsWithChildren } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { openPaymentDisableAtom } from '../../../../../atoms';
import { authAtom } from '../../../../../atoms/index';
import { useCurrentLoginStatus } from '../../../../../hooks/affine/use-current-login-status';
import { useMutation } from '../../../../../hooks/use-mutation';
import { mixpanel } from '../../../../../utils';
import { CancelAction, ResumeAction } from './actions';
import type { DynamicPrice, FixedPrice } from './cloud-plans';
@@ -33,24 +24,23 @@ import * as styles from './style.css';
interface PlanCardProps {
detail: FixedPrice | DynamicPrice;
subscription?: Subscription | null;
recurring: SubscriptionRecurring;
onSubscriptionUpdate: SubscriptionMutator;
onNotify: (info: {
detail: FixedPrice | DynamicPrice;
recurring: string;
}) => void;
}
export const PlanCard = (props: PlanCardProps) => {
const { detail, subscription, recurring } = props;
const loggedIn = useCurrentLoginStatus() === 'authenticated';
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const { detail, recurring } = props;
const loggedIn =
useLiveData(useService(AuthService).session.status$) === 'authenticated';
const subscriptionService = useService(SubscriptionService);
const primarySubscription = useLiveData(
subscriptionService.subscription.primary$
);
const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free;
const isCurrent =
loggedIn &&
detail.plan === currentPlan &&
recurring === subscription?.recurring;
recurring === primarySubscription?.recurring;
const isPro = detail.plan === SubscriptionPlan.Pro;
return (
@@ -97,26 +87,16 @@ export const PlanCard = (props: PlanCardProps) => {
);
};
const ActionButton = ({
detail,
subscription,
recurring,
onSubscriptionUpdate,
onNotify,
}: PlanCardProps) => {
const ActionButton = ({ detail, recurring }: PlanCardProps) => {
const t = useAFFiNEI18N();
const loggedIn = useCurrentLoginStatus() === 'authenticated';
const currentPlan = subscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring = subscription?.recurring;
const mutateAndNotify = useCallback(
(sub: Parameters<SubscriptionMutator>[0]) => {
mixpanel.track_forms('Subscription', detail.plan, sub);
onSubscriptionUpdate?.(sub);
onNotify?.({ detail, recurring });
},
[onSubscriptionUpdate, onNotify, detail, recurring]
const loggedIn =
useLiveData(useService(AuthService).session.status$) === 'authenticated';
const subscriptionService = useService(SubscriptionService);
const primarySubscription = useLiveData(
subscriptionService.subscription.primary$
);
const currentPlan = primarySubscription?.plan ?? SubscriptionPlan.Free;
const currentRecurring = primarySubscription?.recurring;
// branches:
// if contact => 'Contact Sales'
@@ -148,43 +128,33 @@ const ActionButton = ({
);
}
const isCanceled = !!subscription?.canceledAt;
const isCanceled = !!primarySubscription?.canceledAt;
const isFree = detail.plan === SubscriptionPlan.Free;
const isCurrent =
detail.plan === currentPlan &&
(isFree
? true
: currentRecurring === recurring &&
subscription?.status === SubscriptionStatus.Active);
primarySubscription?.status === SubscriptionStatus.Active);
// is current
if (isCurrent) {
return isCanceled ? (
<ResumeButton onSubscriptionUpdate={mutateAndNotify} />
) : (
<CurrentPlan />
);
return isCanceled ? <ResumeButton /> : <CurrentPlan />;
}
if (isFree) {
return (
<Downgrade disabled={isCanceled} onSubscriptionUpdate={mutateAndNotify} />
);
return <Downgrade disabled={isCanceled} />;
}
return currentPlan === detail.plan ? (
<ChangeRecurring
from={currentRecurring as SubscriptionRecurring}
to={recurring as SubscriptionRecurring}
due={subscription?.nextBillAt || ''}
onSubscriptionUpdate={mutateAndNotify}
due={primarySubscription?.nextBillAt || ''}
disabled={isCanceled}
/>
) : (
<Upgrade
recurring={recurring as SubscriptionRecurring}
onSubscriptionUpdate={mutateAndNotify}
/>
<Upgrade recurring={recurring as SubscriptionRecurring} />
);
};
@@ -197,13 +167,7 @@ const CurrentPlan = () => {
);
};
const Downgrade = ({
disabled,
onSubscriptionUpdate,
}: {
disabled?: boolean;
onSubscriptionUpdate: SubscriptionMutator;
}) => {
const Downgrade = ({ disabled }: { disabled?: boolean }) => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
@@ -212,11 +176,7 @@ const Downgrade = ({
: null;
return (
<CancelAction
open={open}
onOpenChange={setOpen}
onSubscriptionUpdate={onSubscriptionUpdate}
>
<CancelAction open={open} onOpenChange={setOpen}>
<Tooltip content={tooltipContent} rootOptions={{ delayDuration: 0 }}>
<div className={styles.planAction}>
<Button
@@ -266,27 +226,25 @@ const BookDemo = ({ plan }: { plan: SubscriptionPlan }) => {
);
};
const Upgrade = ({
recurring,
onSubscriptionUpdate,
}: {
recurring: SubscriptionRecurring;
onSubscriptionUpdate: SubscriptionMutator;
}) => {
const Upgrade = ({ recurring }: { recurring: SubscriptionRecurring }) => {
const [isMutating, setMutating] = useState(false);
const [isOpenedExternalWindow, setOpenedExternalWindow] = useState(false);
const t = useAFFiNEI18N();
const { isMutating, trigger } = useMutation({
mutation: createCheckoutSessionMutation,
});
const newTabRef = useRef<Window | null>(null);
const subscriptionService = useService(SubscriptionService);
// allow replay request on network error until component unmount
const idempotencyKey = useMemo(() => `${nanoid()}-${recurring}`, [recurring]);
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const onClose = useCallback(() => {
newTabRef.current = null;
onSubscriptionUpdate();
}, [onSubscriptionUpdate]);
useEffect(() => {
if (isOpenedExternalWindow) {
// when the external window is opened, revalidate the subscription status every 3 seconds
const timer = setInterval(() => {
subscriptionService.subscription.revalidate();
}, 1000);
return () => clearInterval(timer);
}
return;
}, [isOpenedExternalWindow, subscriptionService]);
const [, openPaymentDisableModal] = useAtom(openPaymentDisableAtom);
const upgrade = useAsyncCallback(async () => {
@@ -295,42 +253,20 @@ const Upgrade = ({
return;
}
if (newTabRef.current) {
newTabRef.current.focus();
} else {
await trigger(
{
input: {
recurring,
idempotencyKey,
plan: SubscriptionPlan.Pro, // Only support prod plan now.
coupon: null,
successCallbackLink: null,
},
},
{
onSuccess: data => {
const newTab = popupWindow(data.createCheckoutSession);
if (newTab) {
newTabRef.current = newTab;
newTab.addEventListener('close', onClose);
}
},
}
);
}
}, [openPaymentDisableModal, trigger, recurring, idempotencyKey, onClose]);
useEffect(() => {
return () => {
if (newTabRef.current) {
newTabRef.current.removeEventListener('close', onClose);
newTabRef.current = null;
}
};
}, [onClose]);
setMutating(true);
const link = await subscriptionService.createCheckoutSession({
recurring,
idempotencyKey,
plan: SubscriptionPlan.Pro, // Only support prod plan now.
coupon: null,
successCallbackLink: null,
});
setMutating(false);
setIdempotencyKey(nanoid());
popupWindow(link);
setOpenedExternalWindow(true);
mixpanel.track_forms('Subscription', SubscriptionPlan.Pro);
}, [openPaymentDisableModal, subscriptionService, recurring, idempotencyKey]);
return (
<Button
@@ -350,34 +286,25 @@ const ChangeRecurring = ({
to,
disabled,
due,
onSubscriptionUpdate,
}: {
from: SubscriptionRecurring;
to: SubscriptionRecurring;
disabled?: boolean;
due: string;
onSubscriptionUpdate: SubscriptionMutator;
}) => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
const [isMutating, setIsMutating] = useState(false);
// allow replay request on network error until component unmount or success
const [idempotencyKey, setIdempotencyKey] = useState(nanoid());
const { isMutating, trigger } = useMutation({
mutation: updateSubscriptionMutation,
});
const subscription = useService(SubscriptionService).subscription;
const change = useAsyncCallback(async () => {
await trigger(
{ recurring: to, idempotencyKey },
{
onSuccess: data => {
// refresh idempotency key
setIdempotencyKey(nanoid());
onSubscriptionUpdate(data.updateSubscriptionRecurring);
},
}
);
}, [trigger, to, idempotencyKey, onSubscriptionUpdate]);
setIsMutating(true);
await subscription.setSubscriptionRecurring(idempotencyKey, to);
setIdempotencyKey(nanoid());
setIsMutating(false);
}, [subscription, to, idempotencyKey]);
const changeCurringContent = (
<Trans values={{ from, to, due }} className={styles.downgradeContent}>
@@ -437,21 +364,13 @@ const SignUpAction = ({ children }: PropsWithChildren) => {
);
};
const ResumeButton = ({
onSubscriptionUpdate,
}: {
onSubscriptionUpdate: SubscriptionMutator;
}) => {
const ResumeButton = () => {
const t = useAFFiNEI18N();
const [open, setOpen] = useState(false);
const [hovered, setHovered] = useState(false);
return (
<ResumeAction
open={open}
onOpenChange={setOpen}
onSubscriptionUpdate={onSubscriptionUpdate}
>
<ResumeAction open={open} onOpenChange={setOpen}>
<Button
className={styles.planAction}
onMouseEnter={() => setHovered(true)}
@@ -6,14 +6,18 @@ import {
openIssueFeedbackModalAtom,
openStarAFFiNEModalAtom,
} from '@affine/core/atoms';
import { AuthService } from '@affine/core/modules/cloud';
import { Trans } from '@affine/i18n';
import { ContactWithUsIcon } from '@blocksuite/icons';
import type { WorkspaceMetadata } from '@toeverything/infra';
import {
useLiveData,
useService,
type WorkspaceMetadata,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { debounce } from 'lodash-es';
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { AccountSetting } from './account-setting';
import { GeneralSetting } from './general-setting';
import { SettingSidebar } from './setting-sidebar';
@@ -48,7 +52,7 @@ const SettingModalInner = ({
onSettingClick,
...modalProps
}: SettingProps) => {
const loginStatus = useCurrentLoginStatus();
const loginStatus = useLiveData(useService(AuthService).session.status$);
const modalContentRef = useRef<HTMLDivElement>(null);
const modalContentWrapperRef = useRef<HTMLDivElement>(null);
@@ -4,10 +4,10 @@ import {
} from '@affine/component/setting-components';
import { Avatar } from '@affine/component/ui/avatar';
import { Tooltip } from '@affine/component/ui/tooltip';
import { useIsWorkspaceOwner } from '@affine/core/hooks/affine/use-is-workspace-owner';
import { useIsEarlyAccess } from '@affine/core/hooks/affine/use-user-features';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { AuthService } from '@affine/core/modules/cloud';
import { UserFeatureService } from '@affine/core/modules/cloud/services/user-feature';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Logo1Icon } from '@blocksuite/icons';
@@ -15,17 +15,15 @@ import type { WorkspaceMetadata } from '@toeverything/infra';
import {
useLiveData,
useService,
Workspace,
WorkspaceManager,
useServices,
WorkspaceService,
WorkspacesService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useAtom } from 'jotai/react';
import type { ReactElement } from 'react';
import { Suspense, useCallback, useMemo } from 'react';
import { Suspense, useCallback, useEffect, useMemo } from 'react';
import { authAtom } from '../../../../atoms';
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-login-status';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { mixpanel } from '../../../../utils';
import { UserPlanButton } from '../../auth/user-plan-button';
import { useGeneralSettingList } from '../general-setting';
@@ -37,11 +35,12 @@ export type UserInfoProps = {
active?: boolean;
};
export const UserInfo = ({
onAccountSettingClick,
active,
}: UserInfoProps): ReactElement => {
const user = useCurrentUser();
export const UserInfo = ({ onAccountSettingClick, active }: UserInfoProps) => {
const account = useLiveData(useService(AuthService).session.account$);
if (!account) {
// TODO: loading ui
return;
}
return (
<div
data-testid="user-info-card"
@@ -52,21 +51,21 @@ export const UserInfo = ({
>
<Avatar
size={28}
name={user.name}
url={user.avatarUrl}
name={account.label}
url={account.avatar}
className="avatar"
/>
<div className="content">
<div className="name-container">
<div className="name" title={user.name}>
{user.name}
<div className="name" title={account.label}>
{account.label}
</div>
<UserPlanButton />
</div>
<div className="email" title={user.email}>
{user.email}
<div className="email" title={account.email}>
{account.email}
</div>
</div>
</div>
@@ -113,7 +112,7 @@ export const SettingSidebar = ({
selectedWorkspaceId: string | null;
}) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrentLoginStatus();
const loginStatus = useLiveData(useService(AuthService).session.status$);
const generalList = useGeneralSettingList();
const onAccountSettingClick = useCallback(() => {
mixpanel.track('Button', {
@@ -207,7 +206,7 @@ export const WorkspaceList = ({
activeSubTab: WorkspaceSubTab;
}) => {
const workspaces = useLiveData(
useService(WorkspaceManager).list.workspaceList$
useService(WorkspacesService).list.workspaces$
);
return (
<>
@@ -257,14 +256,23 @@ const WorkspaceListItem = ({
activeSubTab?: WorkspaceSubTab;
onClick: (subTab: WorkspaceSubTab) => void;
}) => {
const { workspaceService, userFeatureService } = useServices({
WorkspaceService,
UserFeatureService,
});
const information = useWorkspaceInfo(meta);
const avatarUrl = useWorkspaceBlobObjectUrl(meta, information?.avatar);
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
const currentWorkspace = useService(Workspace);
const currentWorkspace = workspaceService.workspace;
const isCurrent = currentWorkspace.id === meta.id;
const t = useAFFiNEI18N();
const isOwner = useIsWorkspaceOwner(meta);
const isEarlyAccess = useIsEarlyAccess();
const isEarlyAccess = useLiveData(
userFeatureService.userFeature.isEarlyAccess$
);
useEffect(() => {
userFeatureService.userFeature.revalidate();
}, [userFeatureService]);
const onClickPreference = useCallback(() => {
onClick('preference');
@@ -274,7 +282,7 @@ const WorkspaceListItem = ({
return subTabConfigs
.filter(({ key }) => {
if (key === 'experimental-features') {
return isOwner && isEarlyAccess;
return information?.isOwner && isEarlyAccess;
}
return true;
})
@@ -294,7 +302,7 @@ const WorkspaceListItem = ({
</div>
);
});
}, [activeSubTab, isEarlyAccess, isOwner, onClick, t]);
}, [activeSubTab, information?.isOwner, isEarlyAccess, onClick, t]);
return (
<>
@@ -1,6 +1,5 @@
import type { WorkspaceMetadata } from '@toeverything/infra';
import { useIsWorkspaceOwner } from '../../../../hooks/affine/use-is-workspace-owner';
import { ExperimentalFeatures } from './experimental-features';
import { WorkspaceSettingDetail } from './new-workspace-setting-detail';
import { WorkspaceSettingProperties } from './properties';
@@ -12,16 +11,9 @@ export const WorkspaceSetting = ({
workspaceMetadata: WorkspaceMetadata;
subTab: 'preference' | 'experimental-features' | 'properties';
}) => {
const isOwner = useIsWorkspaceOwner(workspaceMetadata);
switch (subTab) {
case 'preference':
return (
<WorkspaceSettingDetail
workspaceMetadata={workspaceMetadata}
isOwner={isOwner}
/>
);
return <WorkspaceSettingDetail workspaceMetadata={workspaceMetadata} />;
case 'experimental-features':
return <ExperimentalFeatures />;
case 'properties':
@@ -2,16 +2,18 @@ import { notify } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { ConfirmModal } from '@affine/component/ui/modal';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import {
GlobalContextService,
useLiveData,
useService,
Workspace,
WorkspaceManager,
WorkspaceService,
WorkspacesService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { openSettingModalAtom } from '../../../../../../atoms';
import {
@@ -19,41 +21,45 @@ import {
useNavigateHelper,
} from '../../../../../../hooks/use-navigate-helper';
import { WorkspaceSubPath } from '../../../../../../shared';
import type { WorkspaceSettingDetailProps } from '../types';
import { WorkspaceDeleteModal } from './delete';
export interface DeleteLeaveWorkspaceProps
extends WorkspaceSettingDetailProps {}
export const DeleteLeaveWorkspace = ({
workspaceMetadata,
isOwner,
}: DeleteLeaveWorkspaceProps) => {
export const DeleteLeaveWorkspace = () => {
const t = useAFFiNEI18N();
const workspace = useService(WorkspaceService).workspace;
const { jumpToSubPath, jumpToIndex } = useNavigateHelper();
// fixme: cloud regression
const [showDelete, setShowDelete] = useState(false);
const [showLeave, setShowLeave] = useState(false);
const setSettingModal = useSetAtom(openSettingModalAtom);
const workspaceManager = useService(WorkspaceManager);
const workspaceList = useLiveData(workspaceManager.list.workspaceList$);
const currentWorkspace = useService(Workspace);
const workspacesService = useService(WorkspacesService);
const workspaceList = useLiveData(workspacesService.list.workspaces$);
const currentWorkspaceId = useLiveData(
useService(GlobalContextService).globalContext.workspaceId.$
);
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
useEffect(() => {
permissionService.permission.revalidate();
}, [permissionService]);
const onLeaveOrDelete = useCallback(() => {
if (isOwner) {
setShowDelete(true);
} else {
setShowLeave(true);
if (isOwner !== null) {
if (isOwner) {
setShowDelete(true);
} else {
setShowLeave(true);
}
}
}, [isOwner]);
const onDeleteConfirm = useAsyncCallback(async () => {
setSettingModal(prev => ({ ...prev, open: false, workspaceId: null }));
if (currentWorkspace?.id === workspaceMetadata.id) {
if (currentWorkspaceId === workspace.id) {
const backWorkspace = workspaceList.find(
ws => ws.id !== workspaceMetadata.id
ws => ws.id !== currentWorkspaceId
);
// TODO: if there is no workspace, jump to a new page(wait for design)
if (backWorkspace) {
@@ -67,17 +73,18 @@ export const DeleteLeaveWorkspace = ({
}
}
await workspaceManager.deleteWorkspace(workspaceMetadata);
await workspacesService.deleteWorkspace(workspace.meta);
notify.success({ title: t['Successfully deleted']() });
}, [
currentWorkspace?.id,
jumpToIndex,
jumpToSubPath,
setSettingModal,
currentWorkspaceId,
workspace.id,
workspace.meta,
workspacesService,
t,
workspaceList,
workspaceManager,
workspaceMetadata,
jumpToSubPath,
jumpToIndex,
]);
return (
@@ -102,7 +109,7 @@ export const DeleteLeaveWorkspace = ({
onConfirm={onDeleteConfirm}
open={showDelete}
onOpenChange={setShowDelete}
workspaceMetadata={workspaceMetadata}
workspaceMetadata={workspace.meta}
/>
) : (
<ConfirmModal
@@ -1,30 +1,33 @@
import { SettingRow } from '@affine/component/setting-components';
import { Button } from '@affine/component/ui/button';
import { useEnableCloud } from '@affine/core/hooks/affine/use-enable-cloud';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { Workspace } from '@toeverything/infra';
import {
useLiveData,
useService,
type Workspace,
WorkspaceService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
import { openSettingModalAtom } from '../../../../../atoms';
import { TmpDisableAffineCloudModal } from '../../../tmp-disable-affine-cloud-modal';
import type { WorkspaceSettingDetailProps } from './types';
export interface PublishPanelProps extends WorkspaceSettingDetailProps {
export interface PublishPanelProps {
workspace: Workspace | null;
}
export const EnableCloudPanel = ({
workspaceMetadata,
workspace,
}: PublishPanelProps) => {
export const EnableCloudPanel = () => {
const t = useAFFiNEI18N();
const confirmEnableCloud = useEnableCloud();
const workspaceInfo = useWorkspaceInfo(workspaceMetadata);
const workspace = useService(WorkspaceService).workspace;
const name = useLiveData(workspace.name$);
const flavour = workspace.flavour;
const setSettingModal = useSetAtom(openSettingModalAtom);
const [open, setOpen] = useState(false);
@@ -38,7 +41,7 @@ export const EnableCloudPanel = ({
});
}, [confirmEnableCloud, setSettingModal, workspace]);
if (workspaceMetadata.flavour !== WorkspaceFlavour.LOCAL) {
if (flavour !== WorkspaceFlavour.LOCAL) {
return null;
}
@@ -46,7 +49,7 @@ export const EnableCloudPanel = ({
<>
<SettingRow
name={t['Workspace saved locally']({
name: workspaceInfo?.name ?? UNTITLED_WORKSPACE_NAME,
name: name ?? UNTITLED_WORKSPACE_NAME,
})}
desc={t['Enable cloud hint']()}
spreadCol={false}
@@ -29,7 +29,7 @@ export const ExportPanel = ({
setSaving(true);
try {
if (isOnline) {
await workspace.engine.waitForSynced();
await workspace.engine.waitForDocSynced();
await workspace.engine.blob.sync();
}
@@ -3,12 +3,12 @@ import {
SettingRow,
SettingWrapper,
} from '@affine/component/setting-components';
import { useServerFeatures } from '@affine/core/hooks/affine/use-server-config';
import { useWorkspace } from '@affine/core/hooks/use-workspace';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { FrameworkScope } from '@toeverything/infra';
import { useCallback } from 'react';
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
@@ -20,10 +20,10 @@ import { ProfilePanel } from './profile';
import { StoragePanel } from './storage';
import type { WorkspaceSettingDetailProps } from './types';
export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
export const WorkspaceSettingDetail = ({
workspaceMetadata,
}: WorkspaceSettingDetailProps) => {
const t = useAFFiNEI18N();
const { payment: hasPaymentFeature } = useServerFeatures();
const workspaceMetadata = props.workspaceMetadata;
// useWorkspace hook is a vary heavy operation here, but we need syncing name and avatar changes here,
// we don't have a better way to do this now
@@ -42,8 +42,12 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
});
}, [workspace]);
if (!workspace) {
return null;
}
return (
<>
<FrameworkScope scope={workspace.scope}>
<SettingHeader
title={t[`Workspace Settings with name`]({
name: workspaceInfo?.name ?? UNTITLED_WORKSPACE_NAME,
@@ -56,13 +60,13 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
desc={t['com.affine.settings.workspace.not-owner']()}
spreadCol={false}
>
<ProfilePanel workspace={workspace} {...props} />
<LabelsPanel {...props} />
<ProfilePanel />
<LabelsPanel />
</SettingRow>
</SettingWrapper>
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
<EnableCloudPanel workspace={workspace} {...props} />
<MembersPanel upgradable={hasPaymentFeature} {...props} />
<EnableCloudPanel />
<MembersPanel />
</SettingWrapper>
{environment.isDesktop && (
<SettingWrapper title={t['Storage and Export']()}>
@@ -76,7 +80,7 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
</SettingWrapper>
)}
<SettingWrapper>
<DeleteLeaveWorkspace {...props} />
<DeleteLeaveWorkspace />
<SettingRow
name={
<span style={{ color: 'var(--affine-text-secondary-color)' }}>
@@ -91,6 +95,6 @@ export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
<ArrowRightSmallIcon />
</SettingRow>
</SettingWrapper>
</>
</FrameworkScope>
);
};
@@ -1,9 +1,8 @@
import { useMemo } from 'react';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import * as style from './style.css';
import type { WorkspaceSettingDetailProps } from './types';
export interface LabelsPanelProps extends WorkspaceSettingDetailProps {}
type WorkspaceStatus =
| 'local'
@@ -35,10 +34,13 @@ const Label = ({ value, background }: LabelProps) => {
</div>
);
};
export const LabelsPanel = ({
workspaceMetadata,
isOwner,
}: LabelsPanelProps) => {
export const LabelsPanel = () => {
const workspace = useService(WorkspaceService).workspace;
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
useEffect(() => {
permissionService.permission.revalidate();
}, [permissionService]);
const labelMap: LabelMap = useMemo(
() => ({
local: {
@@ -74,9 +76,9 @@ export const LabelsPanel = ({
);
const labelConditions: labelConditionsProps[] = [
{ condition: !isOwner, label: 'joinedWorkspace' },
{ condition: workspaceMetadata.flavour === 'local', label: 'local' },
{ condition: workspace.flavour === 'local', label: 'local' },
{
condition: workspaceMetadata.flavour === 'affine-cloud',
condition: workspace.flavour === 'affine-cloud',
label: 'syncCloud',
},
//TODO: add these labels
@@ -16,38 +16,45 @@ import { Menu, MenuItem } from '@affine/component/ui/menu';
import { Tooltip } from '@affine/component/ui/tooltip';
import { openSettingModalAtom } from '@affine/core/atoms';
import { AffineErrorBoundary } from '@affine/core/components/affine/affine-error-boundary';
import type { CheckedUser } from '@affine/core/hooks/affine/use-current-user';
import { useCurrentUser } from '@affine/core/hooks/affine/use-current-user';
import { useInviteMember } from '@affine/core/hooks/affine/use-invite-member';
import { useMemberCount } from '@affine/core/hooks/affine/use-member-count';
import type { Member } from '@affine/core/hooks/affine/use-members';
import { useMembers } from '@affine/core/hooks/affine/use-members';
import { useRevokeMemberPermission } from '@affine/core/hooks/affine/use-revoke-member-permission';
import { useWorkspaceQuota } from '@affine/core/hooks/use-quota';
import { useUserSubscription } from '@affine/core/hooks/use-subscription';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Permission, SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MoreVerticalIcon } from '@blocksuite/icons';
import {
useEnsureLiveData,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import {
Suspense,
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
type AuthAccountInfo,
AuthService,
ServerConfigService,
SubscriptionService,
} from '../../../../../modules/cloud';
import * as style from './style.css';
import type { WorkspaceSettingDetailProps } from './types';
const COUNT_PER_PAGE = 8;
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
upgradable: boolean;
}
type OnRevoke = (memberId: string) => void;
const MembersPanelLocal = () => {
const t = useAFFiNEI18N();
@@ -62,13 +69,19 @@ const MembersPanelLocal = () => {
);
};
export const CloudWorkspaceMembersPanel = ({
isOwner,
upgradable,
workspaceMetadata,
}: MembersPanelProps) => {
const workspaceId = workspaceMetadata.id;
const memberCount = useMemberCount(workspaceId);
export const CloudWorkspaceMembersPanel = () => {
const serverConfig = useService(ServerConfigService).serverConfig;
const hasPaymentFeature = useLiveData(
serverConfig.features$.map(f => f?.payment)
);
const workspace = useService(WorkspaceService).workspace;
const memberCount = useMemberCount(workspace.id);
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
useEffect(() => {
permissionService.permission.revalidate();
}, [permissionService]);
const checkMemberCountLimit = useCallback(
(memberCount: number, memberLimit?: number) => {
@@ -78,14 +91,22 @@ export const CloudWorkspaceMembersPanel = ({
[]
);
const quota = useWorkspaceQuota(workspaceId);
const [subscription] = useUserSubscription();
const plan = subscription?.plan ?? SubscriptionPlan.Free;
const isLimited = checkMemberCountLimit(memberCount, quota.memberLimit);
const workspaceQuotaService = useService(WorkspaceQuotaService);
useEffect(() => {
workspaceQuotaService.quota.revalidate();
}, [workspaceQuotaService]);
const workspaceQuota = useLiveData(workspaceQuotaService.quota.quota$);
const subscriptionService = useService(SubscriptionService);
const plan = useLiveData(
subscriptionService.subscription.primary$.map(s => s?.plan)
);
const isLimited = workspaceQuota
? checkMemberCountLimit(memberCount, workspaceQuota.memberLimit)
: null;
const t = useAFFiNEI18N();
const { invite, isMutating } = useInviteMember(workspaceId);
const revokeMemberPermission = useRevokeMemberPermission(workspaceId);
const { invite, isMutating } = useInviteMember(workspace.id);
const revokeMemberPermission = useRevokeMemberPermission(workspace.id);
const [open, setOpen] = useState(false);
const [memberSkip, setMemberSkip] = useState(0);
@@ -150,11 +171,11 @@ export const CloudWorkspaceMembersPanel = ({
);
const desc = useMemo(() => {
if (!quota) return null;
if (!workspaceQuota) return null;
return (
<span>
{t['com.affine.payment.member.description2']()}
{upgradable ? (
{hasPaymentFeature ? (
<div
className={style.goUpgradeWrapper}
onClick={handleUpgradeConfirm}
@@ -166,14 +187,19 @@ export const CloudWorkspaceMembersPanel = ({
) : null}
</span>
);
}, [handleUpgradeConfirm, quota, t, upgradable]);
}, [handleUpgradeConfirm, hasPaymentFeature, t, workspaceQuota]);
if (workspaceQuota === null) {
// TODO: loading ui
return null;
}
return (
<>
<SettingRow
name={`${t['Members']()} (${memberCount}/${quota.humanReadable.memberLimit})`}
name={`${t['Members']()} (${memberCount}/${workspaceQuota.humanReadable.memberLimit})`}
desc={desc}
spreadCol={isOwner}
spreadCol={!!isOwner}
>
{isOwner ? (
<>
@@ -182,8 +208,8 @@ export const CloudWorkspaceMembersPanel = ({
<MemberLimitModal
isFreePlan={plan === SubscriptionPlan.Free}
open={open}
plan={quota.humanReadable.name}
quota={quota.humanReadable.memberLimit}
plan={workspaceQuota?.humanReadable.name ?? ''}
quota={workspaceQuota?.humanReadable.memberLimit ?? ''}
setOpen={setOpen}
onConfirm={handleUpgradeConfirm}
/>
@@ -206,8 +232,8 @@ export const CloudWorkspaceMembersPanel = ({
>
<Suspense fallback={<MemberListFallback memberCount={memberCount} />}>
<MemberList
workspaceId={workspaceId}
isOwner={isOwner}
workspaceId={workspace.id}
isOwner={!!isOwner}
skip={memberSkip}
onRevoke={onRevoke}
/>
@@ -259,16 +285,17 @@ const MemberList = ({
onRevoke: OnRevoke;
}) => {
const members = useMembers(workspaceId, skip, COUNT_PER_PAGE);
const currentUser = useCurrentUser();
const session = useService(AuthService).session;
const account = useEnsureLiveData(session.account$);
return (
<div className={style.memberList}>
{members.map(member => (
<MemberItem
currentAccount={account}
key={member.id}
member={member}
isOwner={isOwner}
currentUser={currentUser}
onRevoke={onRevoke}
/>
))}
@@ -279,12 +306,12 @@ const MemberList = ({
const MemberItem = ({
member,
isOwner,
currentUser,
currentAccount,
onRevoke,
}: {
member: Member;
isOwner: boolean;
currentUser: CheckedUser;
currentAccount: AuthAccountInfo;
onRevoke: OnRevoke;
}) => {
const t = useAFFiNEI18N();
@@ -295,10 +322,10 @@ const MemberItem = ({
const operationButtonInfo = useMemo(() => {
return {
show: isOwner && currentUser.id !== member.id,
show: isOwner && currentAccount.id !== member.id,
leaveOrRevokeText: t['Remove from workspace'](),
};
}, [currentUser.id, isOwner, member.id, t]);
}, [currentAccount.id, isOwner, member.id, t]);
return (
<div
@@ -354,14 +381,15 @@ const MemberItem = ({
);
};
export const MembersPanel = (props: MembersPanelProps): ReactElement | null => {
if (props.workspaceMetadata.flavour === WorkspaceFlavour.LOCAL) {
export const MembersPanel = (): ReactElement | null => {
const workspace = useService(WorkspaceService).workspace;
if (workspace.flavour === WorkspaceFlavour.LOCAL) {
return <MembersPanelLocal />;
}
return (
<AffineErrorBoundary>
<Suspense>
<CloudWorkspaceMembersPanel {...props} />
<CloudWorkspaceMembersPanel />
</Suspense>
</AffineErrorBoundary>
);
@@ -4,25 +4,26 @@ import { Button } from '@affine/component/ui/button';
import { Upload } from '@affine/core/components/pure/file-upload';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useWorkspaceBlobObjectUrl } from '@affine/core/hooks/use-workspace-blob';
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
import { validateAndReduceImage } from '@affine/core/utils/reduce-image';
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CameraIcon } from '@blocksuite/icons';
import type { Workspace } from '@toeverything/infra';
import { useLiveData } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import type { KeyboardEvent, MouseEvent } from 'react';
import { useCallback, useEffect, useState } from 'react';
import * as style from './style.css';
import type { WorkspaceSettingDetailProps } from './types';
export interface ProfilePanelProps extends WorkspaceSettingDetailProps {
workspace: Workspace | null;
}
export const ProfilePanel = ({ isOwner, workspace }: ProfilePanelProps) => {
export const ProfilePanel = () => {
const t = useAFFiNEI18N();
const workspace = useService(WorkspaceService).workspace;
const permissionService = useService(WorkspacePermissionService);
const isOwner = useLiveData(permissionService.permission.isOwner$);
useEffect(() => {
permissionService.permission.revalidate();
}, [permissionService]);
const workspaceIsReady = useLiveData(workspace?.engine.rootDocState$)?.ready;
const [avatarBlob, setAvatarBlob] = useState<string | null>(null);
@@ -1,6 +1,5 @@
import type { WorkspaceMetadata } from '@toeverything/infra';
export interface WorkspaceSettingDetailProps {
isOwner: boolean;
workspaceMetadata: WorkspaceMetadata;
}
@@ -1,13 +1,11 @@
import { Button, IconButton, Menu } from '@affine/component';
import { SettingHeader } from '@affine/component/setting-components';
import { useWorkspacePropertiesAdapter } from '@affine/core/hooks/use-affine-adapter';
import { useWorkspace } from '@affine/core/hooks/use-workspace';
import { useWorkspaceInfo } from '@affine/core/hooks/use-workspace-info';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/workspace/properties/schema';
import type { PageInfoCustomPropertyMeta } from '@affine/core/modules/properties/services/schema';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { DeleteIcon, FilterIcon, MoreHorizontalIcon } from '@blocksuite/icons';
import type { Workspace, WorkspaceMetadata } from '@toeverything/infra';
import { FrameworkScope, type WorkspaceMetadata } from '@toeverything/infra';
import type { MouseEvent } from 'react';
import {
createContext,
@@ -19,6 +17,8 @@ import {
useState,
} from 'react';
import { useCurrentWorkspacePropertiesAdapter } from '../../../../../hooks/use-affine-adapter';
import { useWorkspace } from '../../../../../hooks/use-workspace';
import type { PagePropertyIcon } from '../../../page-properties';
import {
nameToIcon,
@@ -37,11 +37,11 @@ import * as styles from './styles.css';
// @ts-expect-error this should always be set
const managerContext = createContext<PagePropertiesMetaManager>();
const usePagePropertiesMetaManager = (workspace: Workspace) => {
const usePagePropertiesMetaManager = () => {
// the workspace properties adapter adapter is reactive,
// which means it's reference will change when any of the properties change
// also it will trigger a re-render of the component
const adapter = useWorkspacePropertiesAdapter(workspace);
const adapter = useCurrentWorkspacePropertiesAdapter();
const manager = useMemo(() => {
return new PagePropertiesMetaManager(adapter);
}, [adapter]);
@@ -372,12 +372,8 @@ const WorkspaceSettingPropertiesMain = () => {
);
};
const WorkspaceSettingPropertiesInner = ({
workspace,
}: {
workspace: Workspace;
}) => {
const manager = usePagePropertiesMetaManager(workspace);
const WorkspaceSettingPropertiesInner = () => {
const manager = usePagePropertiesMetaManager();
return (
<managerContext.Provider value={manager}>
<WorkspaceSettingPropertiesMain />
@@ -393,10 +389,14 @@ export const WorkspaceSettingProperties = ({
const t = useAFFiNEI18N();
const workspace = useWorkspace(workspaceMetadata);
const workspaceInfo = useWorkspaceInfo(workspaceMetadata);
const title = workspaceInfo.name || 'untitled';
const title = workspaceInfo?.name || 'untitled';
if (workspace === null) {
return null;
}
return (
<>
<FrameworkScope scope={workspace.scope}>
<SettingHeader
title={t['com.affine.settings.workspace.properties.header.title']()}
subtitle={
@@ -410,9 +410,7 @@ export const WorkspaceSettingProperties = ({
</Trans>
}
/>
{workspace ? (
<WorkspaceSettingPropertiesInner workspace={workspace} />
) : null}
</>
<WorkspaceSettingPropertiesInner />
</FrameworkScope>
);
};
@@ -4,7 +4,7 @@ import { ExportMenuItems } from '@affine/core/components/page-list';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { LinkIcon } from '@blocksuite/icons';
import { Doc, useLiveData, useService } from '@toeverything/infra';
import { DocService, useLiveData, useService } from '@toeverything/infra';
import { useExportPage } from '../../../../hooks/affine/use-export-page';
import * as styles from './index.css';
@@ -16,7 +16,7 @@ export const ShareExport = ({
currentPage,
}: ShareMenuProps) => {
const t = useAFFiNEI18N();
const page = useService(Doc);
const doc = useService(DocService).doc;
const workspaceId = workspace.id;
const pageId = currentPage.id;
const { sharingUrl, onClickCopyLink } = useSharingUrl({
@@ -25,7 +25,7 @@ export const ShareExport = ({
urlType: 'workspace',
});
const exportHandler = useExportPage(currentPage);
const currentMode = useLiveData(page.mode$);
const currentMode = useLiveData(doc.mode$);
return (
<>
@@ -8,7 +8,6 @@ import type { Doc } from '@blocksuite/store';
import type { WorkspaceMetadata } from '@toeverything/infra';
import clsx from 'clsx';
import { useIsSharedPage } from '../../../../hooks/affine/use-is-shared-page';
import * as styles from './index.css';
import { ShareExport } from './share-export';
import { SharePage } from './share-page';
@@ -65,11 +64,6 @@ const LocalShareMenu = (props: ShareMenuProps) => {
const CloudShareMenu = (props: ShareMenuProps) => {
const t = useAFFiNEI18N();
const {
workspaceMetadata: { id: workspaceId },
currentPage,
} = props;
const { isSharedPage } = useIsSharedPage(workspaceId, currentPage.id);
return (
<Menu
@@ -87,9 +81,7 @@ const CloudShareMenu = (props: ShareMenuProps) => {
data-testid="cloud-share-menu-button"
type="primary"
>
{isSharedPage
? t['com.affine.share-menu.sharedButton']()
: t['com.affine.share-menu.shareButton']()}
{t['com.affine.share-menu.shareButton']()}
</Button>
</Menu>
);
@@ -1,21 +1,34 @@
import {
Input,
notify,
RadioButton,
RadioButtonGroup,
Skeleton,
Switch,
} from '@affine/component';
import { PublicLinkDisableModal } from '@affine/component/disable-public-link';
import { Button } from '@affine/component/ui/button';
import { Menu, MenuItem, MenuTrigger } from '@affine/component/ui/menu';
import { useIsSharedPage } from '@affine/core/hooks/affine/use-is-shared-page';
import { useServerBaseUrl } from '@affine/core/hooks/affine/use-server-config';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { ShareService } from '@affine/core/modules/share-doc';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { PublicPageMode } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import type { PageMode } from '@toeverything/infra';
import { Doc, useLiveData, useService } from '@toeverything/infra';
import { useCallback, useMemo, useState } from 'react';
import {
ArrowRightSmallIcon,
SingleSelectSelectSolidIcon,
} from '@blocksuite/icons';
import {
type DocMode,
DocService,
useLiveData,
useService,
} from '@toeverything/infra';
import { cssVar } from '@toeverything/theme';
import { Suspense, useEffect, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { ServerConfigService } from '../../../../modules/cloud';
import { CloudSvg } from '../cloud-svg';
import * as styles from './index.css';
import type { ShareMenuProps } from './share-menu';
@@ -50,63 +63,156 @@ export const LocalSharePage = (props: ShareMenuProps) => {
export const AffineSharePage = (props: ShareMenuProps) => {
const {
workspaceMetadata: { id: workspaceId },
currentPage,
} = props;
const pageId = currentPage.id;
const page = useService(Doc);
const doc = useService(DocService).doc;
const shareService = useService(ShareService);
const serverConfig = useService(ServerConfigService).serverConfig;
useEffect(() => {
shareService.share.revalidate();
}, [shareService]);
const isSharedPage = useLiveData(shareService.share.isShared$);
const sharedMode = useLiveData(shareService.share.sharedMode$);
const baseUrl = useLiveData(serverConfig.config$.map(c => c?.baseUrl));
const isLoading =
isSharedPage === null || sharedMode === null || baseUrl === null;
const [showDisable, setShowDisable] = useState(false);
const {
isSharedPage,
enableShare,
changeShare,
currentShareMode,
disableShare,
} = useIsSharedPage(workspaceId, currentPage.id);
const currentPageMode = useLiveData(page.mode$);
const currentDocMode = useLiveData(doc.mode$);
const defaultMode = useMemo(() => {
if (isSharedPage) {
const mode = useMemo(() => {
if (isSharedPage && sharedMode) {
// if it's a shared page, use the share mode
return currentShareMode;
return sharedMode.toLowerCase() as DocMode;
}
// default to page mode
return currentPageMode;
}, [currentPageMode, currentShareMode, isSharedPage]);
const [mode, setMode] = useState<PageMode>(defaultMode);
return currentDocMode;
}, [currentDocMode, isSharedPage, sharedMode]);
const { sharingUrl, onClickCopyLink } = useSharingUrl({
workspaceId,
pageId,
pageId: doc.id,
urlType: 'share',
});
const baseUrl = useServerBaseUrl();
const t = useAFFiNEI18N();
const onClickCreateLink = useCallback(() => {
enableShare(mode);
if (sharingUrl) {
navigator.clipboard.writeText(sharingUrl).catch(err => {
console.error(err);
const onClickCreateLink = useAsyncCallback(async () => {
try {
await shareService.share.enableShare(
mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page
);
notify.success({
title:
t[
'com.affine.share-menu.create-public-link.notification.success.title'
](),
message:
t[
'com.affine.share-menu.create-public-link.notification.success.message'
](),
style: 'normal',
icon: <SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />,
});
if (sharingUrl) {
navigator.clipboard.writeText(sharingUrl).catch(err => {
console.error(err);
});
}
} catch (err) {
notify.error({
title:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.title'
](),
message:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.message'
](),
});
console.error(err);
}
}, [enableShare, mode, sharingUrl]);
}, [mode, shareService.share, sharingUrl, t]);
const onDisablePublic = useCallback(() => {
disableShare();
const onDisablePublic = useAsyncCallback(async () => {
try {
await shareService.share.disableShare();
notify.error({
title:
t[
'com.affine.share-menu.disable-publish-link.notification.success.title'
](),
message:
t[
'com.affine.share-menu.disable-publish-link.notification.success.message'
](),
});
} catch (err) {
notify.error({
title:
t[
'com.affine.share-menu.disable-publish-link.notification.fail.title'
](),
message:
t[
'com.affine.share-menu.disable-publish-link.notification.fail.message'
](),
});
console.log(err);
}
setShowDisable(false);
}, [disableShare]);
}, [shareService, t]);
const onShareModeChange = useCallback(
(value: PageMode) => {
setMode(value);
if (isSharedPage) {
changeShare(value);
const onShareModeChange = useAsyncCallback(
async (value: DocMode) => {
try {
if (isSharedPage) {
await shareService.share.changeShare(
value === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page
);
notify.success({
title:
t[
'com.affine.share-menu.confirm-modify-mode.notification.success.title'
](),
message: t[
'com.affine.share-menu.confirm-modify-mode.notification.success.message'
]({
preMode: value === 'edgeless' ? t['Page']() : t['Edgeless'](),
currentMode: value === 'edgeless' ? t['Edgeless']() : t['Page'](),
}),
style: 'normal',
icon: (
<SingleSelectSelectSolidIcon color={cssVar('primaryColor')} />
),
});
}
} catch (err) {
notify.error({
title:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.title'
](),
message:
t[
'com.affine.share-menu.confirm-modify-mode.notification.fail.message'
](),
});
console.error(err);
}
},
[changeShare, isSharedPage]
[isSharedPage, shareService.share, t]
);
if (isLoading) {
// TODO: loading and error UI
return (
<>
<Skeleton height={100} />
<Skeleton height={40} />
</>
);
}
return (
<>
<div className={styles.titleContainerStyle}>
@@ -124,15 +230,7 @@ export const AffineSharePage = (props: ShareMenuProps) => {
fontSize: 'var(--affine-font-xs)',
lineHeight: '20px',
}}
value={
(isSharedPage && sharingUrl) ||
`${
baseUrl ||
`${location.protocol}${
location.port ? `:${location.port}` : ''
}//${location.hostname}`
}/...`
}
value={(isSharedPage && sharingUrl) || `${baseUrl}/...`}
readOnly
/>
{isSharedPage ? (
@@ -162,7 +260,6 @@ export const AffineSharePage = (props: ShareMenuProps) => {
<div>
<RadioButtonGroup
className={styles.radioButtonGroup}
defaultValue={defaultMode}
value={mode}
onValueChange={onShareModeChange}
>
@@ -236,7 +333,14 @@ export const SharePage = (props: ShareMenuProps) => {
} else if (
props.workspaceMetadata.flavour === WorkspaceFlavour.AFFINE_CLOUD
) {
return <AffineSharePage {...props} />;
return (
// TODO: refactor this part
<ErrorBoundary fallback={null}>
<Suspense>
<AffineSharePage {...props} />
</Suspense>
</ErrorBoundary>
);
}
throw new Error('Unreachable');
};
@@ -1,5 +1,5 @@
import { toast } from '@affine/component';
import { useServerBaseUrl } from '@affine/core/hooks/affine/use-server-config';
import { getAffineCloudBaseUrl } from '@affine/core/modules/cloud/services/fetch';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useCallback, useMemo } from 'react';
@@ -16,7 +16,7 @@ const useGenerateUrl = ({ workspaceId, pageId, urlType }: UseSharingUrl) => {
// to generate a public url like https://app.affine.app/share/123/456
// or https://app.affine.app/share/123/456?mode=edgeless
const baseUrl = useServerBaseUrl();
const baseUrl = getAffineCloudBaseUrl();
const url = useMemo(() => {
// baseUrl is null when running in electron and without network
@@ -1,6 +1,6 @@
import { Skeleton } from '@affine/component';
import { ResizePanel } from '@affine/component/resize-panel';
import { useServiceOptional, Workspace } from '@toeverything/infra';
import { useServiceOptional, WorkspaceService } from '@toeverything/infra';
import { useAtom, useAtomValue } from 'jotai';
import { debounce } from 'lodash-es';
import type { PropsWithChildren, ReactElement } from 'react';
@@ -121,7 +121,7 @@ export function AppSidebar({
export const AppSidebarFallback = (): ReactElement | null => {
const width = useAtomValue(appSidebarWidthAtom);
const currentWorkspace = useServiceOptional(Workspace);
const currentWorkspace = useServiceOptional(WorkspaceService);
return (
<div
style={{ width }}
@@ -6,7 +6,7 @@ import type {
} from '@blocksuite/presets';
import type { Doc } from '@blocksuite/store';
import { Slot } from '@blocksuite/store';
import type { PageMode } from '@toeverything/infra';
import type { DocMode } from '@toeverything/infra';
import clsx from 'clsx';
import type React from 'react';
import type { RefObject } from 'react';
@@ -40,7 +40,7 @@ function forwardSlot<T extends Record<string, Slot<any>>>(
interface BlocksuiteEditorContainerProps {
page: Doc;
mode: PageMode;
mode: DocMode;
className?: string;
style?: React.CSSProperties;
defaultSelectedBlockId?: string;
@@ -1,9 +1,9 @@
import { FavoriteTag } from '@affine/core/components/page-list';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { toast } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useCallback } from 'react';
export interface FavoriteButtonProps {
@@ -12,7 +12,7 @@ export interface FavoriteButtonProps {
export const useFavorite = (pageId: string) => {
const t = useAFFiNEI18N();
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const docCollection = workspace.docCollection;
const currentPage = docCollection.getDoc(pageId);
const favAdapter = useService(FavoriteItemsAdapter);
@@ -11,10 +11,8 @@ import { Export, MoveToTrash } from '@affine/core/components/page-list';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useExportPage } from '@affine/core/hooks/affine/use-export-page';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import {
DuplicateIcon,
EdgelessIcon,
@@ -25,7 +23,12 @@ import {
ImportIcon,
PageIcon,
} from '@blocksuite/icons';
import { Doc, useLiveData, useService, Workspace } from '@toeverything/infra';
import {
DocService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai';
import { useCallback, useState } from 'react';
@@ -46,16 +49,12 @@ export const PageHeaderMenuButton = ({
}: PageMenuProps) => {
const t = useAFFiNEI18N();
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const docCollection = workspace.docCollection;
const currentPage = docCollection.getDoc(pageId);
assertExists(currentPage);
const pageMeta = useBlockSuiteDocMeta(docCollection).find(
meta => meta.id === pageId
);
const page = useService(Doc);
const currentMode = useLiveData(page.mode$);
const doc = useService(DocService).doc;
const isInTrash = useLiveData(doc.meta$.map(m => m.trash));
const currentMode = useLiveData(doc.mode$);
const { favorite, toggleFavorite } = useFavorite(pageId);
@@ -74,30 +73,27 @@ export const PageHeaderMenuButton = ({
}, [setOpenHistoryTipsModal, workspace.flavour]);
const handleOpenTrashModal = useCallback(() => {
if (!pageMeta) {
return;
}
setTrashModal({
open: true,
pageIds: [pageId],
pageTitles: [pageMeta.title],
pageTitles: [doc.meta$.value.title ?? ''],
});
}, [pageId, pageMeta, setTrashModal]);
}, [doc.meta$.value.title, pageId, setTrashModal]);
const handleSwitchMode = useCallback(() => {
page.toggleMode();
doc.toggleMode();
toast(
currentMode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
: t['com.affine.toastMessage.pageMode']()
);
}, [currentMode, page, t]);
}, [currentMode, doc, t]);
const menuItemStyle = {
padding: '4px 12px',
transition: 'all 0.3s',
};
const exportHandler = useExportPage(currentPage);
const exportHandler = useExportPage(doc.blockSuiteDoc);
const handleDuplicate = useCallback(() => {
duplicate(pageId);
@@ -212,7 +208,7 @@ export const PageHeaderMenuButton = ({
/>
</>
);
if (pageMeta?.trash) {
if (isInTrash) {
return null;
}
return (
@@ -1,8 +1,12 @@
import { Tooltip } from '@affine/component/ui/tooltip';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageMode } from '@toeverything/infra';
import { Doc, useLiveData, useService } from '@toeverything/infra';
import {
type DocMode,
DocService,
useLiveData,
useService,
} from '@toeverything/infra';
import type { CSSProperties } from 'react';
import { useCallback, useEffect } from 'react';
@@ -17,7 +21,7 @@ export type EditorModeSwitchProps = {
pageId: string;
style?: CSSProperties;
isPublic?: boolean;
publicMode?: PageMode;
publicMode?: DocMode;
};
const TooltipContent = () => {
const t = useAFFiNEI18N();
@@ -42,9 +46,9 @@ export const EditorModeSwitch = ({
meta => meta.id === pageId
);
const trash = pageMeta?.trash ?? false;
const page = useService(Doc);
const doc = useService(DocService).doc;
const currentMode = useLiveData(page.mode$);
const currentMode = useLiveData(doc.mode$);
useEffect(() => {
if (trash || isPublic) {
@@ -53,7 +57,7 @@ export const EditorModeSwitch = ({
const keydown = (e: KeyboardEvent) => {
if (e.code === 'KeyS' && e.altKey) {
e.preventDefault();
page.toggleMode();
doc.toggleMode();
toast(
currentMode === 'page'
? t['com.affine.toastMessage.edgelessMode']()
@@ -64,7 +68,7 @@ export const EditorModeSwitch = ({
document.addEventListener('keydown', keydown, { capture: true });
return () =>
document.removeEventListener('keydown', keydown, { capture: true });
}, [currentMode, isPublic, page, pageId, t, trash]);
}, [currentMode, isPublic, doc, pageId, t, trash]);
const onSwitchToPageMode = useCallback(() => {
mixpanel.track('Button', {
@@ -73,9 +77,9 @@ export const EditorModeSwitch = ({
if (currentMode === 'page' || isPublic) {
return;
}
page.setMode('page');
doc.setMode('page');
toast(t['com.affine.toastMessage.pageMode']());
}, [currentMode, isPublic, page, t]);
}, [currentMode, isPublic, doc, t]);
const onSwitchToEdgelessMode = useCallback(() => {
mixpanel.track('Button', {
@@ -84,18 +88,18 @@ export const EditorModeSwitch = ({
if (currentMode === 'edgeless' || isPublic) {
return;
}
page.setMode('edgeless');
doc.setMode('edgeless');
toast(t['com.affine.toastMessage.edgelessMode']());
}, [currentMode, isPublic, page, t]);
}, [currentMode, isPublic, doc, t]);
const shouldHide = useCallback(
(mode: PageMode) =>
(mode: DocMode) =>
(trash && currentMode !== mode) || (isPublic && publicMode !== mode),
[currentMode, isPublic, publicMode, trash]
);
const shouldActive = useCallback(
(mode: PageMode) => (isPublic ? false : currentMode === mode),
(mode: DocMode) => (isPublic ? false : currentMode === mode),
[currentMode, isPublic]
);
@@ -3,7 +3,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 { WorkspaceSubPath } from '@affine/core/shared';
import { initEmptyPage, PageRecordList, useService } from '@toeverything/infra';
import { DocsService, initEmptyPage, useService } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
@@ -13,23 +13,23 @@ export const usePageHelper = (docCollection: DocCollection) => {
const { openPage, jumpToSubPath } = useNavigateHelper();
const { createDoc } = useDocCollectionHelper(docCollection);
const { setDocMeta } = useDocMetaHelper(docCollection);
const pageRecordList = useService(PageRecordList);
const docRecordList = useService(DocsService).list;
const isPreferredEdgeless = useCallback(
(pageId: string) =>
pageRecordList.record$(pageId).value?.mode$.value === 'edgeless',
[pageRecordList]
docRecordList.doc$(pageId).value?.mode$.value === 'edgeless',
[docRecordList]
);
const createPageAndOpen = useCallback(
(mode?: 'page' | 'edgeless') => {
const page = createDoc();
initEmptyPage(page);
pageRecordList.record$(page.id).value?.setMode(mode || 'page');
docRecordList.doc$(page.id).value?.setMode(mode || 'page');
openPage(docCollection.id, page.id);
return page;
},
[docCollection.id, createDoc, openPage, pageRecordList]
[docCollection.id, createDoc, openPage, docRecordList]
);
const createEdgelessAndOpen = useCallback(() => {
@@ -1,7 +1,11 @@
import { Button } from '@affine/component/ui/button';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useLiveData, useService, WorkspaceManager } from '@toeverything/infra';
import {
useLiveData,
useService,
WorkspacesService,
} from '@toeverything/infra';
import { useCallback, useEffect } from 'react';
import type { ShareHeaderRightItemProps } from './index';
@@ -13,11 +17,9 @@ export const AuthenticatedItem = ({
}: { setIsMember: (value: boolean) => void } & ShareHeaderRightItemProps) => {
const { workspaceId, pageId } = props;
const workspaceManager = useService(WorkspaceManager);
const workspaceList = useLiveData(workspaceManager.list.workspaceList$);
const isMember = workspaceList?.some(
workspace => workspace.id === workspaceId
);
const workspacesService = useService(WorkspacesService);
const workspaces = useLiveData(workspacesService.list.workspaces$);
const isMember = workspaces?.some(workspace => workspace.id === workspaceId);
const t = useAFFiNEI18N();
const { jumpToPage } = useNavigateHelper();
@@ -1,7 +1,7 @@
import type { PageMode } from '@toeverything/infra';
import { AuthService } from '@affine/core/modules/cloud';
import { type DocMode, useLiveData, useService } from '@toeverything/infra';
import { useState } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { AuthenticatedItem } from './authenticated-item';
import { PresentButton } from './present';
import * as styles from './styles.css';
@@ -10,11 +10,11 @@ import { PublishPageUserAvatar } from './user-avatar';
export type ShareHeaderRightItemProps = {
workspaceId: string;
pageId: string;
publishMode: PageMode;
publishMode: DocMode;
};
const ShareHeaderRightItem = ({ ...props }: ShareHeaderRightItemProps) => {
const loginStatus = useCurrentLoginStatus();
const loginStatus = useLiveData(useService(AuthService).session.status$);
const { publishMode } = props;
const [isMember, setIsMember] = useState(false);
@@ -5,37 +5,44 @@ import {
MenuItem,
MenuSeparator,
} from '@affine/component/ui/menu';
import { useCurrentUser } from '@affine/core/hooks/affine/use-current-user';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { useUserSubscription } from '@affine/core/hooks/use-subscription';
import { signOutCloud } from '@affine/core/utils/cloud-utils';
import { SubscriptionPlan } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SignOutIcon } from '@blocksuite/icons';
import { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useMemo } from 'react';
import { AuthService, SubscriptionService } from '../../../modules/cloud';
import * as styles from './styles.css';
const UserInfo = () => {
const user = useCurrentUser();
const [subscription] = useUserSubscription();
const plan = subscription?.plan ?? SubscriptionPlan.Free;
const authService = useService(AuthService);
const user = useLiveData(authService.session.account$);
const subscription = useService(SubscriptionService).subscription;
useEffect(() => {
subscription.revalidate();
}, [subscription]);
const primary = useLiveData(subscription.primary$);
const plan = primary?.plan;
if (!user) {
// TODO: loading UI
return null;
}
return (
<div className={styles.accountCard}>
<Avatar
size={28}
name={user.name}
url={user.avatarUrl}
name={user.label}
url={user.avatar}
className={styles.avatar}
/>
<div className={styles.content}>
<div className={styles.nameContainer}>
<div className={styles.userName} title={user.name}>
{user.name}
<div className={styles.userName} title={user.label}>
{user.label}
</div>
<div className={styles.userPlanButton}>{plan}</div>
{plan && <div className={styles.userPlanButton}>{plan}</div>}
</div>
<div className={styles.userEmail} title={user.email}>
{user.email}
@@ -46,13 +53,13 @@ const UserInfo = () => {
};
export const PublishPageUserAvatar = () => {
const user = useCurrentUser();
const authService = useService(AuthService);
const user = useLiveData(authService.session.account$);
const t = useAFFiNEI18N();
const location = useLocation();
const handleSignOut = useAsyncCallback(async () => {
await signOutCloud(location.pathname);
}, [location.pathname]);
await authService.signOut();
}, [authService]);
const menuItem = useMemo(() => {
return (
@@ -74,6 +81,10 @@ export const PublishPageUserAvatar = () => {
);
}, [handleSignOut, t]);
if (!user) {
return null;
}
return (
<Menu
items={menuItem}
@@ -84,7 +95,7 @@ export const PublishPageUserAvatar = () => {
}}
>
<div className={styles.iconWrapper} data-testid="share-page-user-avatar">
<Avatar size={24} url={user.avatarUrl} name={user.name} />
<Avatar size={24} url={user.avatar} name={user.label} />
</div>
</Menu>
);
@@ -4,9 +4,9 @@ import { useDocCollectionPage } from '@affine/core/hooks/use-block-suite-workspa
import { assertExists, DisposableGroup } from '@blocksuite/global/utils';
import type { AffineEditorContainer } from '@blocksuite/presets';
import type { Doc as BlockSuiteDoc, DocCollection } from '@blocksuite/store';
import type { PageMode } from '@toeverything/infra';
import {
Doc,
type DocMode,
DocService,
fontStyleOptions,
useLiveData,
useService,
@@ -32,7 +32,7 @@ export type OnLoadEditor = (
export interface PageDetailEditorProps {
isPublic?: boolean;
publishMode?: PageMode;
publishMode?: DocMode;
docCollection: DocCollection;
pageId: string;
onLoad?: OnLoadEditor;
@@ -48,7 +48,7 @@ const PageDetailEditorMain = memo(function PageDetailEditorMain({
isPublic,
publishMode,
}: PageDetailEditorProps & { page: BlockSuiteDoc }) {
const currentMode = useLiveData(useService(Doc).mode$);
const currentMode = useLiveData(useService(DocService).doc.mode$);
const mode = useMemo(() => {
const shareMode = publishMode || currentMode;
@@ -1,7 +1,7 @@
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import type { ReactElement } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
@@ -63,7 +63,7 @@ export const VirtualizedCollectionList = ({
[]
);
const collectionService = useService(CollectionService);
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const info = useDeleteCollectionInfo();
const collectionOperations = useCollectionOperationsRenderer({
@@ -16,8 +16,8 @@ import {
SearchIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { Doc } from '@blocksuite/store';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import type { Doc as BlockSuiteDoc } from '@blocksuite/store';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { nanoid } from 'nanoid';
import { useCallback, useMemo, useState } from 'react';
@@ -37,7 +37,7 @@ import { PageListNewPageButton } from './page-list-new-page-button';
export const PageListHeader = () => {
const t = useAFFiNEI18N();
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const { importFile, createEdgeless, createPage } = usePageHelper(
workspace.docCollection
);
@@ -85,12 +85,12 @@ export const CollectionPageListHeader = ({
collectionService.updateCollection(collection.id, () => ret);
}, [collection, collectionService, open]);
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const { createEdgeless, createPage } = usePageHelper(workspace.docCollection);
const { openConfirmModal } = useConfirmModal();
const createAndAddDocument = useCallback(
(createDocumentFn: () => Doc) => {
(createDocumentFn: () => BlockSuiteDoc) => {
const newDoc = createDocumentFn();
collectionService.addPageToCollection(collection.id, newDoc.id);
},
@@ -98,7 +98,7 @@ export const CollectionPageListHeader = ({
);
const onConfirmAddDocument = useCallback(
(createDocumentFn: () => Doc) => {
(createDocumentFn: () => BlockSuiteDoc) => {
openConfirmModal({
title: t['com.affine.collection.add-doc.confirm.title'](),
description: t['com.affine.collection.add-doc.confirm.description'](),
@@ -248,9 +248,9 @@ interface SwitchTagProps {
export const SwitchTag = ({ onClick }: SwitchTagProps) => {
const t = useAFFiNEI18N();
const [inputValue, setInputValue] = useState('');
const tagService = useService(TagService);
const tagList = useService(TagService).tagList;
const filteredTags = useLiveData(
inputValue ? tagService.filterTagsByName$(inputValue) : tagService.tags$
inputValue ? tagList.filterTagsByName$(inputValue) : tagList.tags$
);
const onInputChange = useCallback(
@@ -73,8 +73,8 @@ const PageSelectionCell = ({
};
export const PageTagsCell = ({ pageId }: Pick<PageListItemProps, 'pageId'>) => {
const tagsService = useService(TagService);
const tags = useLiveData(tagsService.tagsByPageId$(pageId));
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tagsByPageId$(pageId));
return (
<div data-testid="page-list-item-tags" className={styles.tagsCell}>
@@ -7,7 +7,7 @@ import type { Collection, Filter } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DocMeta } from '@blocksuite/store';
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
@@ -70,13 +70,13 @@ export const VirtualizedPageList = ({
const listRef = useRef<ItemListHandle>(null);
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const pageMetas = useBlockSuiteDocMeta(currentWorkspace.docCollection);
const pageOperations = usePageOperationsRenderer();
const { isPreferredEdgeless } = usePageHelper(currentWorkspace.docCollection);
const pageHeaderColsDef = usePageHeaderColsDef();
const filteredPageMetas = useFilteredPageMetas(currentWorkspace, pageMetas, {
const filteredPageMetas = useFilteredPageMetas(pageMetas, {
filters,
collection,
});
@@ -1,6 +1,6 @@
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Tag } from '@affine/core/modules/tag';
import { TagService } from '@affine/core/modules/tag';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FavoritedIcon, FavoriteIcon } from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
@@ -127,8 +127,8 @@ const GroupTagLabel = ({ tag, count }: { tag: Tag; count: number }) => {
);
};
export const useTagGroupDefinitions = (): ItemGroupDefinition<ListItem>[] => {
const tagService = useService(TagService);
const tags = useLiveData(tagService.tags$);
const tagList = useService(TagService).tagList;
const tags = useLiveData(tagList.tags$);
return useMemo(() => {
return tags.map(tag => ({
id: tag.id,
@@ -11,8 +11,8 @@ import {
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '@affine/core/hooks/affine/use-block-suite-meta-helper';
import { useTrashModalHelper } from '@affine/core/hooks/affine/use-trash-modal-helper';
import { Workbench } from '@affine/core/modules/workbench';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
@@ -31,7 +31,7 @@ import {
SplitViewIcon,
} from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, Workspace } from '@toeverything/infra';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
@@ -58,13 +58,13 @@ export const PageOperationCell = ({
onRemoveFromAllowList,
}: PageOperationCellProps) => {
const t = useAFFiNEI18N();
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const { appSettings } = useAppSettingHelper();
const { setTrashModal } = useTrashModalHelper(currentWorkspace.docCollection);
const [openDisableShared, setOpenDisableShared] = useState(false);
const favAdapter = useService(FavoriteItemsAdapter);
const favourite = useLiveData(favAdapter.isFavorite$(page.id, 'doc'));
const workbench = useService(Workbench);
const workbench = useService(WorkbenchService).workbench;
const { duplicate } = useBlockSuiteMetaHelper(currentWorkspace.docCollection);
const onDisablePublicSharing = useCallback(() => {
@@ -11,7 +11,7 @@ import {
} from '@blocksuite/icons';
import type { DocCollection, DocMeta } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
import { PageRecordList, useLiveData, useService } from '@toeverything/infra';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { selectAtom } from 'jotai/utils';
import type { MouseEventHandler } from 'react';
@@ -273,12 +273,8 @@ function tagIdToTagOption(
}
const PageTitle = ({ id }: { id: string }) => {
const page = useLiveData(
useService(PageRecordList).records$.map(record => {
return record.find(p => p.id === id);
})
);
const title = useLiveData(page?.title$);
const doc = useLiveData(useService(DocsService).list.doc$(id));
const title = useLiveData(doc?.title$);
const t = useAFFiNEI18N();
return title || t['Untitled']();
};
@@ -32,9 +32,9 @@ export const CreateOrEditTag = ({
onOpenChange: (open: boolean) => void;
tagMeta?: TagMeta;
}) => {
const tagService = useService(TagService);
const tagOptions = useLiveData(tagService.tagMetas$);
const tag = useLiveData(tagService.tagByTagId$(tagMeta?.id));
const tagList = useService(TagService).tagList;
const tagOptions = useLiveData(tagList.tagMetas$);
const tag = useLiveData(tagList.tagByTagId$(tagMeta?.id));
const t = useAFFiNEI18N();
const [menuOpen, setMenuOpen] = useState(false);
@@ -97,7 +97,7 @@ export const CreateOrEditTag = ({
return toast(t['com.affine.tags.create-tag.toast.exist']());
}
if (!tagMeta) {
tagService.createTag(tagName.trim(), tagIcon);
tagList.createTag(tagName.trim(), tagIcon);
toast(t['com.affine.tags.create-tag.toast.success']());
onClose();
return;
@@ -108,7 +108,7 @@ export const CreateOrEditTag = ({
toast(t['com.affine.tags.edit-tag.toast.success']());
onClose();
return;
}, [onClose, t, tag, tagIcon, tagMeta, tagName, tagOptions, tagService]);
}, [onClose, t, tag, tagIcon, tagMeta, tagName, tagOptions, tagList]);
useEffect(() => {
if (!open) return;
@@ -1,6 +1,6 @@
import type { Tag } from '@affine/core/modules/tag';
import { Trans } from '@affine/i18n';
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useCallback, useMemo, useRef, useState } from 'react';
import { ListFloatingToolbar } from '../components/list-floating-toolbar';
@@ -26,7 +26,7 @@ export const VirtualizedTagList = ({
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [showCreateTagInput, setShowCreateTagInput] = useState(false);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const currentWorkspace = useService(Workspace);
const currentWorkspace = useService(WorkspaceService).workspace;
const tagOperations = useCallback(
(tag: TagMeta) => {
@@ -1,4 +1,4 @@
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useAtom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import { useCallback } from 'react';
@@ -30,7 +30,7 @@ export const useAllDocDisplayProperties = (): [
value: PageGroupByType | PageDisplayProperties
) => void,
] => {
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const [properties, setProperties] = useAtom(displayPropertiesAtom);
const workspaceProperties = properties[workspace.id] || defaultProps;
@@ -1,14 +1,14 @@
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { ShareDocsService } 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';
import { useLiveData, useService, type Workspace } from '@toeverything/infra';
import { useMemo } from 'react';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useEffect, useMemo } from 'react';
import { usePublicPages } from '../../hooks/affine/use-is-shared-page';
import { filterPage, filterPageByRules } from './use-collection-manager';
export const useFilteredPageMetas = (
workspace: Workspace,
pageMetas: DocMeta[],
options: {
trash?: boolean;
@@ -16,7 +16,26 @@ export const useFilteredPageMetas = (
collection?: Collection;
} = {}
) => {
const { getPublicMode } = usePublicPages(workspace);
const shareDocsService = useService(ShareDocsService);
const shareDocs = useLiveData(shareDocsService.shareDocs.list$);
const getPublicMode = useCallback(
(id: string) => {
const mode = shareDocs?.find(shareDoc => shareDoc.id === id)?.mode;
return mode
? mode === PublicPageMode.Edgeless
? ('edgeless' as const)
: ('page' as const)
: undefined;
},
[shareDocs]
);
useEffect(() => {
// TODO: loading & error UI
shareDocsService.shareDocs.revalidate();
}, [shareDocsService]);
const favAdapter = useService(FavoriteItemsAdapter);
const favoriteItems = useLiveData(favAdapter.favorites$);
@@ -2,8 +2,8 @@ import type { MenuItemProps } from '@affine/component';
import { Menu, MenuIcon, MenuItem } from '@affine/component';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { useDeleteCollectionInfo } from '@affine/core/hooks/affine/use-delete-collection-info';
import { Workbench } from '@affine/core/modules/workbench';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
@@ -42,7 +42,7 @@ export const CollectionOperations = ({
const deleteInfo = useDeleteCollectionInfo();
const { appSettings } = useAppSettingHelper();
const service = useService(CollectionService);
const workbench = useService(Workbench);
const workbench = useService(WorkbenchService).workbench;
const { open: openEditCollectionModal, node: editModal } =
useEditCollection(config);
const t = useAFFiNEI18N();
@@ -1,5 +1,5 @@
import { Menu } from '@affine/component';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
@@ -1,5 +1,5 @@
import { Tooltip } from '@affine/component';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Collection } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
@@ -1,5 +1,5 @@
import { Button, Menu } from '@affine/component';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { FilterIcon } from '@blocksuite/icons';
@@ -1,7 +1,4 @@
import {
useBlockSuiteDocMeta,
useDocMetaHelper,
} from '@affine/core/hooks/use-block-suite-page-meta';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { useGetDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { useJournalHelper } from '@affine/core/hooks/use-journal';
import { CollectionService } from '@affine/core/modules/collection';
@@ -14,17 +11,20 @@ import {
TodayIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { DocMeta } from '@blocksuite/store';
import type { AffineCommand, CommandCategory } from '@toeverything/infra';
import type {
AffineCommand,
CommandCategory,
DocRecord,
Workspace,
} from '@toeverything/infra';
import {
AffineCommandRegistry,
Doc,
PageRecordList,
DocsService,
GlobalContextService,
PreconditionStrategy,
useLiveData,
useService,
useServiceOptional,
Workspace,
WorkspaceService,
} from '@toeverything/infra';
import { atom, useAtomValue } from 'jotai';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -51,13 +51,13 @@ function filterCommandByContext(
return true;
}
if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) {
return context.pageMode === 'edgeless';
return context.docMode === 'edgeless';
}
if (command.preconditionStrategy === PreconditionStrategy.InPaper) {
return context.pageMode === 'page';
return context.docMode === 'page';
}
if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) {
return !!context.pageMode;
return !!context.docMode;
}
if (command.preconditionStrategy === PreconditionStrategy.Never) {
return false;
@@ -75,28 +75,22 @@ function getAllCommand(context: CommandContext) {
});
}
const useWorkspacePages = () => {
const workspace = useService(Workspace);
const pages = useBlockSuiteDocMeta(workspace.docCollection);
return pages;
};
const useRecentPages = () => {
const pages = useWorkspacePages();
const useRecentDocs = () => {
const docs = useLiveData(useService(DocsService).list.docs$);
const recentPageIds = useAtomValue(recentPageIdsBaseAtom);
return useMemo(() => {
return recentPageIds
.map(pageId => {
const page = pages.find(page => page.id === pageId);
const page = docs.find(page => page.id === pageId);
return page;
})
.filter((p): p is DocMeta => !!p);
}, [recentPageIds, pages]);
.filter((p): p is DocRecord => !!p);
}, [recentPageIds, docs]);
};
export const pageToCommand = (
export const docToCommand = (
category: CommandCategory,
page: DocMeta,
doc: DocRecord,
navigationHelper: ReturnType<typeof useNavigateHelper>,
getPageTitle: ReturnType<typeof useGetDocCollectionPageTitle>,
isPageJournal: (pageId: string) => boolean,
@@ -105,10 +99,9 @@ export const pageToCommand = (
subTitle?: string,
blockId?: string
): CMDKCommand => {
const pageMode = workspace.services.get(PageRecordList).record$(page.id).value
?.mode$.value;
const docMode = doc.mode$.value;
const title = getPageTitle(page.id) || t['Untitled']();
const title = getPageTitle(doc.id) || t['Untitled']();
const commandLabel = {
title: title,
subTitle: subTitle,
@@ -116,11 +109,11 @@ export const pageToCommand = (
// hack: when comparing, the part between >>> and <<< will be ignored
// adding this patch so that CMDK will not complain about duplicated commands
const id = category + '.' + page.id;
const id = category + '.' + doc.id;
const icon = isPageJournal(page.id) ? (
const icon = isPageJournal(doc.id) ? (
<TodayIcon />
) : pageMode === 'edgeless' ? (
) : docMode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PageIcon />
@@ -136,19 +129,19 @@ export const pageToCommand = (
return;
}
if (blockId) {
return navigationHelper.jumpToPageBlock(workspace.id, page.id, blockId);
return navigationHelper.jumpToPageBlock(workspace.id, doc.id, blockId);
}
return navigationHelper.jumpToPage(workspace.id, page.id);
return navigationHelper.jumpToPage(workspace.id, doc.id);
},
icon: icon,
timestamp: page.updatedDate,
timestamp: doc.meta?.updatedDate,
};
};
export const usePageCommands = () => {
const recentPages = useRecentPages();
const pages = useWorkspacePages();
const workspace = useService(Workspace);
const recentDocs = useRecentDocs();
const docs = useLiveData(useService(DocsService).list.docs$);
const workspace = useService(WorkspaceService).workspace;
const pageHelper = usePageHelper(workspace.docCollection);
const pageMetaHelper = useDocMetaHelper(workspace.docCollection);
const query = useAtomValue(cmdkQueryAtom);
@@ -179,10 +172,10 @@ export const usePageCommands = () => {
let results: CMDKCommand[] = [];
if (query.trim() === '') {
results = recentPages.map(page => {
return pageToCommand(
results = recentDocs.map(doc => {
return docToCommand(
'affine:recent',
page,
doc,
navigationHelper,
getPageTitle,
isPageJournal,
@@ -203,18 +196,18 @@ export const usePageCommands = () => {
reverseMapping.set(value.space, key);
});
results = pages.map(page => {
results = docs.map(doc => {
const category = 'affine:pages';
const subTitle = resultValues.find(
result => result.space === page.id
result => result.space === doc.id
)?.content;
const blockId = reverseMapping.get(page.id);
const blockId = reverseMapping.get(doc.id);
const command = pageToCommand(
const command = docToCommand(
category,
page,
doc,
navigationHelper,
getPageTitle,
isPageJournal,
@@ -281,13 +274,13 @@ export const usePageCommands = () => {
}, [
searchTime,
query,
recentPages,
recentDocs,
navigationHelper,
getPageTitle,
isPageJournal,
t,
workspace,
pages,
docs,
journalHelper,
pageHelper,
pageMetaHelper,
@@ -322,7 +315,7 @@ export const useCollectionsCommands = () => {
const query = useAtomValue(cmdkQueryAtom);
const navigationHelper = useNavigateHelper();
const t = useAFFiNEI18N();
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const selectCollection = useCallback(
(id: string) => {
navigationHelper.jumpToCollection(workspace.id, id);
@@ -353,13 +346,14 @@ export const useCMDKCommandGroups = () => {
const pageCommands = usePageCommands();
const collectionCommands = useCollectionsCommands();
const currentPage = useServiceOptional(Doc);
const currentPageMode = useLiveData(currentPage?.mode$);
const currentDocMode =
useLiveData(useService(GlobalContextService).globalContext.docMode.$) ??
undefined;
const affineCommands = useMemo(() => {
return getAllCommand({
pageMode: currentPageMode,
docMode: currentDocMode,
});
}, [currentPageMode]);
}, [currentDocMode]);
const query = useAtomValue(cmdkQueryAtom).trim();
return useMemo(() => {
@@ -1,7 +1,7 @@
import type { CommandCategory } from '@toeverything/infra';
import type { CommandCategory, DocMode } from '@toeverything/infra';
export interface CommandContext {
pageMode: 'page' | 'edgeless' | undefined;
docMode: DocMode | undefined;
}
// similar to AffineCommand, but for rendering into the UI
@@ -1,98 +0,0 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons';
import type { CSSProperties, FC } from 'react';
import { forwardRef, useCallback } from 'react';
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
import { stringToColour } from '../../../utils';
import { signInCloud } from '../../../utils/cloud-utils';
import { StyledFooter, StyledSignInButton } from './styles';
export const Footer: FC = () => {
const loginStatus = useCurrentLoginStatus();
// const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
return (
<StyledFooter data-testid="workspace-list-modal-footer">
{loginStatus === 'authenticated' ? null : <SignInButton />}
</StyledFooter>
);
};
const SignInButton = () => {
const t = useAFFiNEI18N();
return (
<StyledSignInButton
data-testid="sign-in-button"
onClick={useCallback(() => {
signInCloud('email').catch(console.error);
}, [])}
>
<div className="circle">
<CloudWorkspaceIcon />
</div>
{t['Sign in']()}
</StyledSignInButton>
);
};
interface WorkspaceAvatarProps {
size: number;
name: string | undefined;
avatar: string | undefined;
style?: CSSProperties;
}
export const WorkspaceAvatar = forwardRef<HTMLDivElement, WorkspaceAvatarProps>(
function WorkspaceAvatar(props, ref) {
const size = props.size || 20;
const sizeStr = size + 'px';
return props.avatar ? (
<div
style={{
...props.style,
width: sizeStr,
height: sizeStr,
color: '#fff',
borderRadius: '50%',
overflow: 'hidden',
display: 'inline-block',
verticalAlign: 'middle',
}}
ref={ref}
>
<picture>
<img
style={{ width: sizeStr, height: sizeStr }}
src={props.avatar}
alt=""
referrerPolicy="no-referrer"
/>
</picture>
</div>
) : (
<div
style={{
...props.style,
width: sizeStr,
height: sizeStr,
border: '1px solid #fff',
color: '#fff',
fontSize: Math.ceil(0.5 * size) + 'px',
background: stringToColour(props.name || 'AFFiNE'),
borderRadius: '50%',
textAlign: 'center',
lineHeight: size + 'px',
display: 'inline-block',
verticalAlign: 'middle',
}}
ref={ref}
>
{(props.name || 'AFFiNE').substring(0, 1)}
</div>
);
}
);
@@ -1,148 +0,0 @@
import {
displayFlex,
displayInlineFlex,
styled,
textEllipsis,
} from '@affine/component';
export const StyledSplitLine = styled('div')(() => {
return {
width: '1px',
height: '20px',
background: 'var(--affine-border-color)',
marginRight: '24px',
};
});
export const StyleWorkspaceInfo = styled('div')(() => {
return {
marginLeft: '15px',
width: '202px',
p: {
height: '20px',
fontSize: 'var(--affine-font-sm)',
...displayFlex('flex-start', 'center'),
},
svg: {
marginRight: '10px',
fontSize: '16px',
flexShrink: 0,
},
span: {
flexGrow: 1,
...textEllipsis(1),
},
};
});
export const StyleWorkspaceTitle = styled('div')(() => {
return {
fontSize: 'var(--affine-font-base)',
fontWeight: 600,
lineHeight: '24px',
marginBottom: '10px',
maxWidth: '200px',
...textEllipsis(1),
};
});
export const StyledFooter = styled('div')({
padding: '20px 40px',
flexShrink: 0,
...displayFlex('space-between', 'center'),
});
export const StyleUserInfo = styled('div')({
textAlign: 'left',
marginLeft: '16px',
flex: 1,
p: {
lineHeight: '24px',
color: 'var(--affine-icon-color)',
},
'p:first-of-type': {
color: 'var(--affine-text-primary-color)',
fontWeight: 600,
},
});
export const StyledModalHeaderLeft = styled('div')(() => {
return { ...displayFlex('flex-start', 'center') };
});
export const StyledModalTitle = styled('div')(() => {
return {
fontWeight: 600,
fontSize: 'var(--affine-font-h6)',
};
});
export const StyledHelperContainer = styled('div')(() => {
return {
color: 'var(--affine-icon-color)',
marginLeft: '15px',
fontWeight: 400,
fontSize: 'var(--affine-font-h6)',
...displayFlex('center', 'center'),
};
});
export const StyledModalContent = styled('div')({
height: '534px',
padding: '8px 40px',
marginTop: '72px',
overflow: 'auto',
...displayFlex('space-between', 'flex-start', 'flex-start'),
flexWrap: 'wrap',
});
export const StyledOperationWrapper = styled('div')(() => {
return {
...displayFlex('flex-end', 'center'),
};
});
export const StyleWorkspaceAdd = styled('div')(() => {
return {
width: '58px',
height: '58px',
borderRadius: '100%',
background: '#f4f5fa',
border: '1.5px dashed #f4f5fa',
transition: 'background .2s',
...displayFlex('center', 'center'),
};
});
export const StyledModalHeader = styled('div')({
width: '100%',
height: '72px',
position: 'absolute',
left: 0,
top: 0,
borderRadius: '24px 24px 0 0',
padding: '0 40px',
...displayFlex('space-between', 'center'),
});
export const StyledSignInButton = styled('button')(() => {
return {
fontWeight: 600,
paddingLeft: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingRight: '15px',
borderRadius: '8px',
'&:hover': {
backgroundColor: 'var(--affine-hover-color)',
},
'.circle': {
width: '40px',
height: '40px',
borderRadius: '20px',
color: 'var(--affine-primary-color)',
fontSize: '24px',
flexShrink: 0,
marginRight: '16px',
...displayInlineFlex('center', 'center'),
},
};
});
@@ -2,7 +2,12 @@ import { Tooltip } from '@affine/component/ui/tooltip';
import { popupWindow } from '@affine/core/utils';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon, NewIcon } from '@blocksuite/icons';
import { Doc, useLiveData, useServiceOptional } from '@toeverything/infra';
import {
DocsService,
GlobalContextService,
useLiveData,
useService,
} from '@toeverything/infra';
import { useSetAtom } from 'jotai/react';
import { useCallback, useState } from 'react';
@@ -28,9 +33,12 @@ type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts';
const showList = environment.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST;
export const HelpIsland = () => {
const page = useServiceOptional(Doc);
const pageId = page?.id;
const mode = useLiveData(page?.mode$);
const docId = useLiveData(
useService(GlobalContextService).globalContext.docId.$
);
const docRecordList = useService(DocsService).list;
const doc = useLiveData(docId ? docRecordList.doc$(docId) : undefined);
const mode = useLiveData(doc?.mode$);
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
const [spread, setShowSpread] = useState(false);
const t = useAFFiNEI18N();
@@ -61,7 +69,7 @@ export const HelpIsland = () => {
onClick={() => {
setShowSpread(!spread);
}}
inEdgelessPage={!!pageId && mode === 'edgeless'}
inEdgelessPage={!!docId && mode === 'edgeless'}
>
<StyledAnimateWrapper
style={{ height: spread ? `${showList.length * 40 + 4}px` : 0 }}
@@ -1,31 +1,22 @@
import { Button } from '@affine/component/ui/button';
import { ConfirmModal } from '@affine/component/ui/modal';
import { Tooltip } from '@affine/component/ui/tooltip';
import { useBlockSuiteDocMeta } from '@affine/core/hooks/use-block-suite-page-meta';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { assertExists } from '@blocksuite/global/utils';
import { DeleteIcon, ResetIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import { DocService, useService, WorkspaceService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { useAppSettingHelper } from '../../../hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { CurrentWorkspaceService } from '../../../modules/workspace/current-workspace';
import { WorkspaceSubPath } from '../../../shared';
import { toast } from '../../../utils';
import * as styles from './styles.css';
export const TrashPageFooter = ({ pageId }: { pageId: string }) => {
const workspace = useLiveData(
useService(CurrentWorkspaceService).currentWorkspace$
);
assertExists(workspace);
export const TrashPageFooter = () => {
const workspace = useService(WorkspaceService).workspace;
const docCollection = workspace.docCollection;
const pageMeta = useBlockSuiteDocMeta(docCollection).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const doc = useService(DocService).doc;
const t = useAFFiNEI18N();
const { appSettings } = useAppSettingHelper();
const { jumpToSubPath } = useNavigateHelper();
@@ -34,19 +25,19 @@ export const TrashPageFooter = ({ pageId }: { pageId: string }) => {
const hintText = t['com.affine.cmdk.affine.editor.trash-footer-hint']();
const onRestore = useCallback(() => {
restoreFromTrash(pageId);
restoreFromTrash(doc.id);
toast(
t['com.affine.toastMessage.restored']({
title: pageMeta.title || 'Untitled',
title: doc.meta$.value.title || 'Untitled',
})
);
}, [pageId, pageMeta.title, restoreFromTrash, t]);
}, [doc.id, doc.meta$.value.title, restoreFromTrash, t]);
const onConfirmDelete = useCallback(() => {
jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
docCollection.removeDoc(pageId);
docCollection.removeDoc(doc.id);
toast(t['com.affine.toastMessage.permanentlyDeleted']());
}, [docCollection, jumpToSubPath, pageId, workspace.id, t]);
}, [jumpToSubPath, workspace.id, docCollection, doc.id, t]);
const onDelete = useCallback(() => {
setOpen(true);
@@ -4,7 +4,7 @@ import { allPageFilterSelectAtom } from '@affine/core/atoms';
import { useNavigateHelper } from '@affine/core/hooks/use-navigate-helper';
import { WorkspaceSubPath } from '@affine/core/shared';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useService, Workspace } from '@toeverything/infra';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useAtom } from 'jotai';
import { useCallback, useEffect, useState } from 'react';
@@ -15,7 +15,7 @@ export const WorkspaceModeFilterTab = ({
}: {
activeFilter: AllPageFilterOption;
}) => {
const workspace = useService(Workspace);
const workspace = useService(WorkspaceService).workspace;
const t = useAFFiNEI18N();
const [value, setValue] = useState(activeFilter);
const [filterMode, setFilterMode] = useAtom(allPageFilterSelectAtom);
@@ -18,7 +18,7 @@ import {
resolveDragEndIntent,
} from '@affine/core/hooks/affine/use-global-dnd-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import type { Collection } from '@affine/env/filter';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
@@ -35,13 +35,13 @@ import { useCallback, useMemo, useState } from 'react';
import { useAllPageListConfig } from '../../../../hooks/affine/use-all-page-list-config';
import { useBlockSuiteDocMeta } from '../../../../hooks/use-block-suite-page-meta';
import { Workbench } from '../../../../modules/workbench';
import { WorkbenchService } from '../../../../modules/workbench';
import { WorkbenchLink } from '../../../../modules/workbench/view/workbench-link';
import { MenuLinkItem as SidebarMenuLinkItem } from '../../../app-sidebar';
import { DragMenuItemOverlay } from '../components/drag-menu-item-overlay';
import * as draggableMenuItemStyles from '../components/draggable-menu-item.css';
import type { CollectionsListProps } from '../index';
import { Page } from './page';
import { Doc } from './doc';
import * as styles from './styles.css';
const animateLayoutChanges: AnimateLayoutChanges = ({
@@ -131,8 +131,11 @@ export const CollectionSidebarNavItem = ({
};
return filterPage(collection, pageData);
});
const location = useLiveData(useService(Workbench).location$);
const currentPath = location.pathname;
const currentPath = useLiveData(
useService(WorkbenchService).workbench.location$.map(
location => location.pathname
)
);
const path = `/collection/${collection.id}`;
const onRename = useCallback(
@@ -231,12 +234,12 @@ export const CollectionSidebarNavItem = ({
<div style={{ marginLeft: 20, marginTop: -4 }}>
{pagesToRender.map(page => {
return (
<Page
<Doc
parentId={dndId}
inAllowList={allowList.has(page.id)}
removeFromAllowList={removeFromAllowList}
allPageMeta={allPagesMeta}
page={page}
doc={page}
key={page.id}
docCollection={docCollection}
/>
@@ -1,11 +1,11 @@
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
import { Workbench } from '@affine/core/modules/workbench';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { DocCollection, DocMeta } from '@blocksuite/store';
import { useDraggable } from '@dnd-kit/core';
import * as Collapsible from '@radix-ui/react-collapsible';
import { PageRecordList, useLiveData, useService } from '@toeverything/infra';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import React, { useCallback, useMemo } from 'react';
import {
@@ -19,8 +19,8 @@ import { PostfixItem } from '../components/postfix-item';
import { ReferencePage } from '../components/reference-page';
import * as styles from './styles.css';
export const Page = ({
page,
export const Doc = ({
doc,
parentId,
docCollection,
allPageMeta,
@@ -28,47 +28,47 @@ export const Page = ({
removeFromAllowList,
}: {
parentId: DNDIdentifier;
page: DocMeta;
doc: DocMeta;
inAllowList: boolean;
removeFromAllowList: (id: string) => void;
docCollection: DocCollection;
allPageMeta: Record<string, DocMeta>;
}) => {
const [collapsed, setCollapsed] = React.useState(true);
const workbench = useService(Workbench);
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
const t = useAFFiNEI18N();
const pageId = page.id;
const active = location.pathname === '/' + pageId;
const pageRecord = useLiveData(useService(PageRecordList).record$(pageId));
const pageMode = useLiveData(pageRecord?.mode$);
const dragItemId = getDNDId('collection-list', 'doc', pageId, parentId);
const docId = doc.id;
const active = location.pathname === '/' + docId;
const docRecord = useLiveData(useService(DocsService).list.doc$(docId));
const docMode = useLiveData(docRecord?.mode$);
const dragItemId = getDNDId('collection-list', 'doc', docId, parentId);
const icon = useMemo(() => {
return pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [pageMode]);
return docMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;
}, [docMode]);
const { jumpToPage } = useNavigateHelper();
const clickPage = useCallback(() => {
jumpToPage(docCollection.id, page.id);
}, [jumpToPage, page.id, docCollection.id]);
const clickDoc = useCallback(() => {
jumpToPage(docCollection.id, doc.id);
}, [jumpToPage, doc.id, docCollection.id]);
const references = useBlockSuitePageReferences(docCollection, pageId);
const references = useBlockSuitePageReferences(docCollection, docId);
const referencesToRender = references.filter(
id => allPageMeta[id] && !allPageMeta[id]?.trash
);
const pageTitle = page.title || t['Untitled']();
const pageTitleElement = useMemo(() => {
return <DragMenuItemOverlay icon={icon} title={pageTitle} />;
}, [icon, pageTitle]);
const docTitle = doc.title || t['Untitled']();
const docTitleElement = useMemo(() => {
return <DragMenuItemOverlay icon={icon} title={docTitle} />;
}, [icon, docTitle]);
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
id: dragItemId,
data: {
preview: pageTitleElement,
preview: docTitleElement,
},
});
@@ -82,7 +82,7 @@ export const Page = ({
data-testid="collection-page"
data-type="collection-list-item"
icon={icon}
onClick={clickPage}
onClick={clickDoc}
className={styles.title}
active={active}
collapsed={referencesToRender.length > 0 ? collapsed : undefined}
@@ -90,8 +90,8 @@ export const Page = ({
postfix={
<PostfixItem
docCollection={docCollection}
pageId={pageId}
pageTitle={pageTitle}
pageId={docId}
pageTitle={docTitle}
removeFromAllowList={removeFromAllowList}
inAllowList={inAllowList}
/>
@@ -100,7 +100,7 @@ export const Page = ({
{...attributes}
{...listeners}
>
{page.title || t['Untitled']()}
{doc.title || t['Untitled']()}
</CollectionItem>
<Collapsible.Content className={styles.collapsibleContent}>
{referencesToRender.map(id => {
@@ -110,7 +110,7 @@ export const Page = ({
docCollection={docCollection}
pageId={id}
metaMapping={allPageMeta}
parentIds={new Set([pageId])}
parentIds={new Set([docId])}
/>
);
})}
@@ -1,2 +1,2 @@
export * from './collections-list';
export { Page } from './page';
export { Doc } from './doc';
@@ -1,8 +1,8 @@
import { toast } from '@affine/component';
import { IconButton } from '@affine/component/ui/button';
import { Menu } from '@affine/component/ui/menu';
import { Workbench } from '@affine/core/modules/workbench';
import { FavoriteItemsAdapter } from '@affine/core/modules/workspace';
import { FavoriteItemsAdapter } from '@affine/core/modules/properties';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MoreHorizontalIcon } from '@blocksuite/icons';
import type { DocCollection } from '@blocksuite/store';
@@ -40,7 +40,7 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
const { setTrashModal } = useTrashModalHelper(docCollection);
const favAdapter = useService(FavoriteItemsAdapter);
const workbench = useService(Workbench);
const workbench = useService(WorkbenchService).workbench;
const handleRename = useCallback(() => {
setRenameModalOpen?.();
@@ -1,10 +1,10 @@
import { useBlockSuitePageReferences } from '@affine/core/hooks/use-block-suite-page-references';
import { Workbench } from '@affine/core/modules/workbench';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
import type { DocCollection, DocMeta } from '@blocksuite/store';
import * as Collapsible from '@radix-ui/react-collapsible';
import { PageRecordList, useLiveData, useService } from '@toeverything/infra';
import { DocsService, useLiveData, useService } from '@toeverything/infra';
import { useMemo, useState } from 'react';
import { MenuLinkItem } from '../../../app-sidebar';
@@ -24,11 +24,11 @@ export const ReferencePage = ({
parentIds,
}: ReferencePageProps) => {
const t = useAFFiNEI18N();
const workbench = useService(Workbench);
const workbench = useService(WorkbenchService).workbench;
const location = useLiveData(workbench.location$);
const active = location.pathname === '/' + pageId;
const pageRecord = useLiveData(useService(PageRecordList).record$(pageId));
const pageRecord = useLiveData(useService(DocsService).list.doc$(pageId));
const pageMode = useLiveData(pageRecord?.mode$);
const icon = useMemo(() => {
return pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />;

Some files were not shown because too many files have changed in this diff Show More