mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-21 12:02:15 +08:00
feat(ios): refresh mobile settings layout (#15234)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Redesigned mobile Settings with localized sections, promotional content, support actions, subscription access, and improved account controls. - Added sign-in prompts for signed-out users and clearer device-session handling. - Added a guided account-deletion flow with email confirmation and completion messaging. - Added App Store, download, team invitation, rating, and external support links. - **Bug Fixes** - Improved interactive row behavior, keyboard accessibility, navigation, and subscription availability. - Prevented account deletion for unresolved team owners. - **Style** - Refreshed mobile settings, subscription, profile, and promotional card layouts. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
@@ -41,8 +41,9 @@ fi
|
|||||||
FFI_TARGET=${1}
|
FFI_TARGET=${1}
|
||||||
# path to source code root
|
# path to source code root
|
||||||
SRC_ROOT=${2}
|
SRC_ROOT=${2}
|
||||||
# Keep Cargo artifacts in a stable location that the rest of this script can reference.
|
# Keep Cargo artifacts in a stable repo-local location so Xcode does not inherit
|
||||||
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$SRC_ROOT/../../../target}"
|
# a sandbox-specific CARGO_TARGET_DIR from the parent shell.
|
||||||
|
export CARGO_TARGET_DIR="$SRC_ROOT/../../../target"
|
||||||
# buildvariant from our xcconfigs
|
# buildvariant from our xcconfigs
|
||||||
BUILDVARIANT=$(echo "${3}" | tr '[:upper:]' '[:lower:]')
|
BUILDVARIANT=$(echo "${3}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { ConfirmModal } from '@affine/component';
|
||||||
|
import {
|
||||||
|
RouteLogic,
|
||||||
|
useNavigateHelper,
|
||||||
|
} from '@affine/core/components/hooks/use-navigate-helper';
|
||||||
|
import type {
|
||||||
|
DialogComponentProps,
|
||||||
|
GLOBAL_DIALOG_SCHEMA,
|
||||||
|
} from '@affine/core/modules/dialogs';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
export const DeletedAccountDialog = ({
|
||||||
|
close,
|
||||||
|
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['deleted-account']>) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const { jumpToIndex } = useNavigateHelper();
|
||||||
|
|
||||||
|
const handleDone = useCallback(() => {
|
||||||
|
close();
|
||||||
|
jumpToIndex(RouteLogic.REPLACE);
|
||||||
|
}, [close, jumpToIndex]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmModal
|
||||||
|
open
|
||||||
|
persistent
|
||||||
|
title={t['com.affine.setting.account.delete.success-title']()}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<span>
|
||||||
|
{t['com.affine.setting.account.delete.success-description-1']()}
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
{t['com.affine.setting.account.delete.success-description-2']()}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
confirmText={t['Done']()}
|
||||||
|
onOpenChange={handleDone}
|
||||||
|
onConfirm={handleDone}
|
||||||
|
confirmButtonOptions={{
|
||||||
|
variant: 'primary',
|
||||||
|
}}
|
||||||
|
cancelButtonOptions={{
|
||||||
|
style: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
|
import type { WORKSPACE_DIALOG_SCHEMA } from '@affine/core/modules/dialogs/constant';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import { DeletedAccountDialog } from './deleted-account';
|
||||||
import { CollectionSelectorDialog } from './selectors/collection-selector';
|
import { CollectionSelectorDialog } from './selectors/collection-selector';
|
||||||
import { DateSelectorDialog } from './selectors/date-selector';
|
import { DateSelectorDialog } from './selectors/date-selector';
|
||||||
import { DocSelectorDialog } from './selectors/doc-selector';
|
import { DocSelectorDialog } from './selectors/doc-selector';
|
||||||
@@ -16,6 +17,7 @@ import { SignInDialog } from './sign-in';
|
|||||||
|
|
||||||
const GLOBAL_DIALOGS = {
|
const GLOBAL_DIALOGS = {
|
||||||
'sign-in': SignInDialog,
|
'sign-in': SignInDialog,
|
||||||
|
'deleted-account': DeletedAccountDialog,
|
||||||
} satisfies {
|
} satisfies {
|
||||||
[key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC<
|
[key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC<
|
||||||
DialogComponentProps<GLOBAL_DIALOG_SCHEMA[key]>
|
DialogComponentProps<GLOBAL_DIALOG_SCHEMA[key]>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.4 KiB |
@@ -4,7 +4,7 @@ import {
|
|||||||
type DeviceAuthSession,
|
type DeviceAuthSession,
|
||||||
} from '@affine/core/modules/cloud';
|
} from '@affine/core/modules/cloud';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { SettingGroup } from '../group';
|
import { SettingGroup } from '../group';
|
||||||
@@ -15,10 +15,16 @@ const loadFailedToastId = 'mobile-settings-devices-load-failed';
|
|||||||
export const DevicesGroup = () => {
|
export const DevicesGroup = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const auth = useService(AuthService);
|
const auth = useService(AuthService);
|
||||||
|
const loginStatus = useLiveData(auth.session.status$);
|
||||||
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
|
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
|
||||||
const dismissTimer = useRef<number | undefined>(undefined);
|
const dismissTimer = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
|
if (loginStatus !== 'authenticated') {
|
||||||
|
setSessions([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void auth
|
void auth
|
||||||
.listDeviceSessions()
|
.listDeviceSessions()
|
||||||
.then(setSessions)
|
.then(setSessions)
|
||||||
@@ -36,7 +42,7 @@ export const DevicesGroup = () => {
|
|||||||
5000
|
5000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}, [auth, t]);
|
}, [auth, loginStatus, t]);
|
||||||
|
|
||||||
useEffect(reload, [reload]);
|
useEffect(reload, [reload]);
|
||||||
useEffect(
|
useEffect(
|
||||||
@@ -71,6 +77,10 @@ export const DevicesGroup = () => {
|
|||||||
[auth, reload, t]
|
[auth, reload, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (loginStatus !== 'authenticated' || sessions.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingGroup title={t['com.affine.settings.devices.title']()}>
|
<SettingGroup title={t['com.affine.settings.devices.title']()}>
|
||||||
{sessions.map(session => (
|
{sessions.map(session => (
|
||||||
|
|||||||
@@ -16,22 +16,17 @@ import * as styles from './styles.css';
|
|||||||
|
|
||||||
export const ExperimentalFeatureSetting = () => {
|
export const ExperimentalFeatureSetting = () => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const t = useI18n();
|
||||||
|
const title = t['com.affine.mobile.setting.experimental.features']();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingGroup title="Experimental">
|
<SettingGroup title={t['com.affine.mobile.setting.experimental.title']()}>
|
||||||
<RowLayout
|
<RowLayout label={title} onClick={() => setOpen(true)}>
|
||||||
label={'Experimental Features'}
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
>
|
|
||||||
<ArrowRightSmallIcon fontSize={22} />
|
<ArrowRightSmallIcon fontSize={22} />
|
||||||
</RowLayout>
|
</RowLayout>
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
<SwipeDialog
|
<SwipeDialog open={open} onOpenChange={setOpen} title={title}>
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
title="Experimental Features"
|
|
||||||
>
|
|
||||||
<ExperimentalFeatureList />
|
<ExperimentalFeatureList />
|
||||||
</SwipeDialog>
|
</SwipeDialog>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const group = style({
|
export const group = style({
|
||||||
@@ -6,3 +7,16 @@ export const group = style({
|
|||||||
gap: 4,
|
gap: 4,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const groupTitle = style({
|
||||||
|
color: cssVarV2('text/tertiary'),
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: '18px',
|
||||||
|
padding: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const groupContent = style({
|
||||||
|
gap: 0,
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
});
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ export const SettingGroup = forwardRef<HTMLDivElement, SettingGroupProps>(
|
|||||||
<ConfigModal.RowGroup
|
<ConfigModal.RowGroup
|
||||||
{...attrs}
|
{...attrs}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
title={title}
|
title={
|
||||||
|
title ? <div className={styles.groupTitle}>{title}</div> : undefined
|
||||||
|
}
|
||||||
className={clsx(styles.group, className)}
|
className={clsx(styles.group, className)}
|
||||||
contentClassName={contentClassName}
|
contentClassName={clsx(styles.groupContent, contentClassName)}
|
||||||
contentStyle={contentStyle}
|
contentStyle={contentStyle}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -1,38 +1,159 @@
|
|||||||
|
import { notify } from '@affine/component';
|
||||||
import { AuthService } from '@affine/core/modules/cloud';
|
import { AuthService } from '@affine/core/modules/cloud';
|
||||||
import type {
|
import type {
|
||||||
DialogComponentProps,
|
DialogComponentProps,
|
||||||
WORKSPACE_DIALOG_SCHEMA,
|
WORKSPACE_DIALOG_SCHEMA,
|
||||||
} from '@affine/core/modules/dialogs';
|
} from '@affine/core/modules/dialogs';
|
||||||
|
import { UrlService } from '@affine/core/modules/url';
|
||||||
|
import { copyTextToClipboard } from '@affine/core/utils/clipboard';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useEffect } from 'react';
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
import { AboutGroup } from './about';
|
import { AboutGroup } from './about';
|
||||||
import { AppearanceGroup } from './appearance';
|
import { AppearanceGroup } from './appearance';
|
||||||
|
import teamPeople from './assets/team-people.png';
|
||||||
import { DevicesGroup } from './devices';
|
import { DevicesGroup } from './devices';
|
||||||
import { ExperimentalFeatureSetting } from './experimental';
|
import { ExperimentalFeatureSetting } from './experimental';
|
||||||
|
import { SettingGroup } from './group';
|
||||||
import { OthersGroup } from './others';
|
import { OthersGroup } from './others';
|
||||||
|
import { DeleteAccount } from './others/delete-account';
|
||||||
|
import { RowLayout } from './row.layout';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
import { UserSubscription } from './subscription';
|
import { UserSubscription } from './subscription';
|
||||||
import { SwipeDialog } from './swipe-dialog';
|
import { SwipeDialog } from './swipe-dialog';
|
||||||
import { UserProfile } from './user-profile';
|
import { UserProfile } from './user-profile';
|
||||||
import { UserUsage } from './user-usage';
|
import { UserUsage } from './user-usage';
|
||||||
|
|
||||||
const MobileSetting = () => {
|
const AFFINE_MOBILE_STORE_URL = BUILD_CONFIG.isIOS
|
||||||
|
? 'https://apps.apple.com/app/notes-whiteboard-ai-affine/id6736937980'
|
||||||
|
: BUILD_CONFIG.isAndroid
|
||||||
|
? 'https://play.google.com/store/apps/details?id=app.affine.pro'
|
||||||
|
: undefined;
|
||||||
|
const AFFINE_DOWNLOAD_URL = 'https://affine.pro/download';
|
||||||
|
const AFFINE_TEAM_URL = 'https://affine.pro/teamhub';
|
||||||
|
|
||||||
|
const SupportGroup = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const urlService = useService(UrlService);
|
||||||
|
|
||||||
|
const shareApp = useCallback(async () => {
|
||||||
|
const shareData = {
|
||||||
|
title: 'AFFiNE',
|
||||||
|
text: t['com.affine.mobile.setting.support.invite-message'](),
|
||||||
|
url: AFFINE_DOWNLOAD_URL,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ('share' in navigator && typeof navigator.share === 'function') {
|
||||||
|
try {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copied = await copyTextToClipboard(AFFINE_DOWNLOAD_URL);
|
||||||
|
if (copied) {
|
||||||
|
notify.success({ title: t['Copied link to clipboard']() });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
urlService.openExternal(AFFINE_DOWNLOAD_URL);
|
||||||
|
}, [t, urlService]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingGroup title={t['com.affine.mobile.setting.support.title']()}>
|
||||||
|
{AFFINE_MOBILE_STORE_URL ? (
|
||||||
|
<RowLayout
|
||||||
|
label={t['com.affine.mobile.setting.support.rate']()}
|
||||||
|
onClick={() => urlService.openExternal(AFFINE_MOBILE_STORE_URL)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<RowLayout
|
||||||
|
label={t['com.affine.mobile.setting.support.invite']()}
|
||||||
|
onClick={() => void shareApp()}
|
||||||
|
/>
|
||||||
|
</SettingGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const TeamPromotionCard = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const urlService = useService(UrlService);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.promoCard}
|
||||||
|
onClick={() => urlService.openExternal(AFFINE_TEAM_URL)}
|
||||||
|
>
|
||||||
|
<span className={styles.promoCardContent}>
|
||||||
|
<span className={styles.promoCardTitle}>
|
||||||
|
{t['com.affine.mobile.setting.promo.title']()}
|
||||||
|
</span>
|
||||||
|
<span className={styles.promoCardDescription}>
|
||||||
|
{t['com.affine.mobile.setting.promo.description']()}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<img className={styles.promoCardArt} src={teamPeople} alt="" />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DangerZoneGroup = ({
|
||||||
|
onDeleteFinished,
|
||||||
|
}: {
|
||||||
|
onDeleteFinished?: () => void;
|
||||||
|
}) => {
|
||||||
|
const t = useI18n();
|
||||||
|
const authService = useService(AuthService);
|
||||||
|
const account = useLiveData(authService.session.account$);
|
||||||
|
|
||||||
|
if (!account) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingGroup
|
||||||
|
title={
|
||||||
|
<span className={styles.dangerZoneTitle}>
|
||||||
|
{t['com.affine.mobile.setting.danger-zone.title']()}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<DeleteAccount onDeleteFinished={onDeleteFinished} />
|
||||||
|
</SettingGroup>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MobileSetting = ({
|
||||||
|
onDeleteFinished,
|
||||||
|
}: {
|
||||||
|
onDeleteFinished?: () => void;
|
||||||
|
}) => {
|
||||||
const session = useService(AuthService).session;
|
const session = useService(AuthService).session;
|
||||||
const status = useLiveData(session.status$);
|
const status = useLiveData(session.status$);
|
||||||
useEffect(() => session.revalidate(), [session]);
|
|
||||||
|
useEffect(() => {
|
||||||
|
session.revalidate();
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<UserProfile />
|
|
||||||
<UserSubscription />
|
<UserSubscription />
|
||||||
|
<UserProfile />
|
||||||
<UserUsage />
|
<UserUsage />
|
||||||
{status === 'authenticated' ? <DevicesGroup /> : null}
|
{status === 'authenticated' ? <DevicesGroup /> : null}
|
||||||
<AppearanceGroup />
|
<AppearanceGroup />
|
||||||
<AboutGroup />
|
<AboutGroup />
|
||||||
<ExperimentalFeatureSetting />
|
<ExperimentalFeatureSetting />
|
||||||
|
<TeamPromotionCard />
|
||||||
|
<SupportGroup />
|
||||||
<OthersGroup />
|
<OthersGroup />
|
||||||
|
<DangerZoneGroup onDeleteFinished={onDeleteFinished} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -48,18 +169,7 @@ export const SettingDialog = ({
|
|||||||
open
|
open
|
||||||
onOpenChange={() => close()}
|
onOpenChange={() => close()}
|
||||||
>
|
>
|
||||||
<MobileSetting />
|
<MobileSetting onDeleteFinished={close} />
|
||||||
</SwipeDialog>
|
</SwipeDialog>
|
||||||
);
|
);
|
||||||
|
|
||||||
// return (
|
|
||||||
// <ConfigModal
|
|
||||||
// title={t['com.affine.mobile.setting.header-title']()}
|
|
||||||
// open
|
|
||||||
// onOpenChange={() => close()}
|
|
||||||
// onBack={close}
|
|
||||||
// >
|
|
||||||
// <MobileSetting />
|
|
||||||
// </ConfigModal>
|
|
||||||
// );
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const description = style({
|
export const description = style({
|
||||||
fontSize: cssVar('fontBase'),
|
fontSize: cssVar('fontBase'),
|
||||||
lineHeight: 1.6,
|
lineHeight: 1.6,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const deleteAccountLabel = style({
|
||||||
|
color: cssVarV2('status/error'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const inputWrapper = style({
|
||||||
|
marginTop: '12px',
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,408 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment happy-dom
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import type * as Infra from '@toeverything/infra';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
|
||||||
|
type ProfileInfo = {
|
||||||
|
isOwner?: boolean;
|
||||||
|
isTeam?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubjectLike<T> = {
|
||||||
|
next: (value: T) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccount = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||||
|
const notifyError = vi.hoisted(() => vi.fn());
|
||||||
|
const trackDeleteAccount = vi.hoisted(() => vi.fn());
|
||||||
|
const liveDataFrom = vi.hoisted(() => vi.fn());
|
||||||
|
const accountState = vi.hoisted(() => ({
|
||||||
|
value: {
|
||||||
|
email: 'user@example.com',
|
||||||
|
label: 'User',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const workspaceState = vi.hoisted(() => ({
|
||||||
|
workspaces$: null as SubjectLike<unknown[]> | null,
|
||||||
|
profile$: null as SubjectLike<ProfileInfo | null> | null,
|
||||||
|
profileLoading$: null as SubjectLike<boolean> | null,
|
||||||
|
revalidate: null as { mockClear: () => void } | null,
|
||||||
|
}));
|
||||||
|
const authSessionAccountStream = vi.hoisted(() =>
|
||||||
|
Symbol('authSessionAccount$')
|
||||||
|
);
|
||||||
|
const AuthServiceToken = vi.hoisted(() => class AuthService {});
|
||||||
|
const ServerServiceToken = vi.hoisted(() => class ServerService {});
|
||||||
|
const WorkspacesServiceToken = vi.hoisted(() => class WorkspacesService {});
|
||||||
|
|
||||||
|
vi.mock('@affine/component', () => ({
|
||||||
|
ConfirmModal: ({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
confirmButtonOptions,
|
||||||
|
cancelButtonOptions,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
onOpenChange,
|
||||||
|
}: any) => {
|
||||||
|
if (!open) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>{title}</div>
|
||||||
|
<div>{description}</div>
|
||||||
|
{children}
|
||||||
|
{onCancel !== false ? (
|
||||||
|
<button
|
||||||
|
onClick={event => {
|
||||||
|
cancelButtonOptions?.onClick?.(event);
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
onCancel?.();
|
||||||
|
}
|
||||||
|
if (!event.defaultPrevented) {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
disabled={confirmButtonOptions?.disabled}
|
||||||
|
onClick={event => {
|
||||||
|
if (confirmButtonOptions?.onClick) {
|
||||||
|
confirmButtonOptions.onClick(event);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onConfirm?.();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
Input: ({ onChange, ...props }: any) => (
|
||||||
|
<input
|
||||||
|
{...props}
|
||||||
|
onChange={event => {
|
||||||
|
onChange?.(event.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
notify: {
|
||||||
|
error: notifyError,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@affine/core/modules/cloud', () => ({
|
||||||
|
AuthService: AuthServiceToken,
|
||||||
|
ServerService: ServerServiceToken,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@affine/core/modules/workspace', () => ({
|
||||||
|
WorkspacesService: WorkspacesServiceToken,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@affine/error', () => ({
|
||||||
|
UserFriendlyError: {
|
||||||
|
fromAny: (error: unknown) => error,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@affine/i18n', () => ({
|
||||||
|
Trans: ({ i18nKey }: { i18nKey: string }) => <span>{i18nKey}</span>,
|
||||||
|
useI18n: () =>
|
||||||
|
new Proxy(
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
get: (_, key: string) => () => key,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@affine/track', () => ({
|
||||||
|
track: {
|
||||||
|
['$']: {
|
||||||
|
['$']: {
|
||||||
|
auth: {
|
||||||
|
deleteAccount: trackDeleteAccount,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@toeverything/infra', async () => {
|
||||||
|
const actual = await vi.importActual<typeof Infra>('@toeverything/infra');
|
||||||
|
const { BehaviorSubject } = await import('rxjs');
|
||||||
|
|
||||||
|
const workspaces$ = new BehaviorSubject<unknown[]>([]);
|
||||||
|
const profile$ = new BehaviorSubject<ProfileInfo | null>(null);
|
||||||
|
const profileLoading$ = new BehaviorSubject(false);
|
||||||
|
const profile = {
|
||||||
|
profile$,
|
||||||
|
isLoading$: profileLoading$,
|
||||||
|
revalidate: vi.fn(),
|
||||||
|
};
|
||||||
|
workspaceState.workspaces$ = workspaces$;
|
||||||
|
workspaceState.profile$ = profile$;
|
||||||
|
workspaceState.profileLoading$ = profileLoading$;
|
||||||
|
workspaceState.revalidate = profile.revalidate;
|
||||||
|
liveDataFrom.mockImplementation((source, initialValue) =>
|
||||||
|
actual.LiveData.from(source, initialValue)
|
||||||
|
);
|
||||||
|
|
||||||
|
const authService = {
|
||||||
|
session: {
|
||||||
|
['account$']: authSessionAccountStream,
|
||||||
|
},
|
||||||
|
deleteAccount,
|
||||||
|
};
|
||||||
|
const serverService = {
|
||||||
|
server: {
|
||||||
|
id: 'affine-cloud',
|
||||||
|
baseUrl: 'https://affine.pro',
|
||||||
|
['config$']: {
|
||||||
|
value: {
|
||||||
|
serverName: 'AFFiNE Cloud',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const workspacesService = {
|
||||||
|
list: {
|
||||||
|
['workspaces$']: workspaces$,
|
||||||
|
},
|
||||||
|
getProfile: vi.fn(() => profile),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
LiveData: {
|
||||||
|
from: liveDataFrom,
|
||||||
|
},
|
||||||
|
useLiveData: (source: unknown) => {
|
||||||
|
if (source === authSessionAccountStream) {
|
||||||
|
return accountState.value;
|
||||||
|
}
|
||||||
|
if (typeof source === 'object' && source !== null && 'value' in source) {
|
||||||
|
return (source as { value: unknown }).value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
},
|
||||||
|
useService: (token: unknown) => {
|
||||||
|
if (token === AuthServiceToken) {
|
||||||
|
return authService;
|
||||||
|
}
|
||||||
|
if (token === ServerServiceToken) {
|
||||||
|
return serverService;
|
||||||
|
}
|
||||||
|
if (token === WorkspacesServiceToken) {
|
||||||
|
return workspacesService;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../row.layout', () => ({
|
||||||
|
RowLayout: ({
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
label: ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
}) => <button onClick={onClick}>{label}</button>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./delete-account.css', () => ({
|
||||||
|
deleteAccountLabel: 'deleteAccountLabel',
|
||||||
|
description: 'description',
|
||||||
|
inputWrapper: 'inputWrapper',
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { DeleteAccount } from './delete-account';
|
||||||
|
|
||||||
|
describe('DeleteAccount mobile flow', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
deleteAccount.mockClear();
|
||||||
|
notifyError.mockClear();
|
||||||
|
trackDeleteAccount.mockClear();
|
||||||
|
liveDataFrom.mockClear();
|
||||||
|
workspaceState.workspaces$?.next([]);
|
||||||
|
workspaceState.profile$?.next(null);
|
||||||
|
workspaceState.profileLoading$?.next(false);
|
||||||
|
workspaceState.revalidate?.mockClear();
|
||||||
|
accountState.value = {
|
||||||
|
email: 'user@example.com',
|
||||||
|
label: 'User',
|
||||||
|
};
|
||||||
|
deleteAccount.mockResolvedValue(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns to the warning step when cancelling the email confirmation step', async () => {
|
||||||
|
render(<DeleteAccount />);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.mobile.setting.others.delete-account',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('com.affine.setting.account.delete.confirm-title')
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText(
|
||||||
|
'com.affine.setting.account.delete.email-confirm-title'
|
||||||
|
)
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByText('com.affine.setting.account.delete.confirm-title')
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'com.affine.setting.account.delete.email-confirm-title'
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deletes the account after confirming the account email', async () => {
|
||||||
|
const onDeleteFinished = vi.fn();
|
||||||
|
|
||||||
|
render(<DeleteAccount onDeleteFinished={onDeleteFinished} />);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.mobile.setting.others.delete-account',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: { value: ' USER@EXAMPLE.COM ' },
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.setting.account.delete.confirm-button',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(deleteAccount).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
expect(trackDeleteAccount).toHaveBeenCalledOnce();
|
||||||
|
expect(onDeleteFinished).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows an error and clears loading when account deletion fails', async () => {
|
||||||
|
const error = new Error('delete failed');
|
||||||
|
deleteAccount.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
render(<DeleteAccount />);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.mobile.setting.others.delete-account',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||||
|
fireEvent.change(screen.getByRole('textbox'), {
|
||||||
|
target: { value: 'user@example.com' },
|
||||||
|
});
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.setting.account.delete.confirm-button',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(notifyError).toHaveBeenCalledWith(error);
|
||||||
|
});
|
||||||
|
expect(trackDeleteAccount).not.toHaveBeenCalled();
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.setting.account.delete.confirm-button',
|
||||||
|
})
|
||||||
|
).not.toHaveProperty('disabled', true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blocks account deletion for team workspace owners', () => {
|
||||||
|
workspaceState.workspaces$?.next([{ id: 'team-workspace' }]);
|
||||||
|
workspaceState.profile$?.next({ isTeam: true, isOwner: true });
|
||||||
|
|
||||||
|
render(<DeleteAccount />);
|
||||||
|
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.mobile.setting.others.delete-account',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByText('com.affine.setting.account.delete.team-warning-title')
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
screen.queryByText(
|
||||||
|
'com.affine.setting.account.delete.email-confirm-title'
|
||||||
|
)
|
||||||
|
).toBeNull();
|
||||||
|
expect(workspaceState.revalidate).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hides account deletion until workspace ownership is known', () => {
|
||||||
|
workspaceState.workspaces$?.next([{ id: 'workspace' }]);
|
||||||
|
workspaceState.profileLoading$?.next(true);
|
||||||
|
|
||||||
|
render(<DeleteAccount />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', {
|
||||||
|
name: 'com.affine.mobile.setting.others.delete-account',
|
||||||
|
})
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps account deletion available after a failed profile load', () => {
|
||||||
|
workspaceState.workspaces$?.next([{ id: 'workspace' }]);
|
||||||
|
|
||||||
|
render(<DeleteAccount />);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', {
|
||||||
|
name: 'com.affine.mobile.setting.others.delete-account',
|
||||||
|
})
|
||||||
|
).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,48 +1,93 @@
|
|||||||
import { ConfirmModal, notify, useConfirmModal } from '@affine/component';
|
import { ConfirmModal, Input, notify } from '@affine/component';
|
||||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
import { AuthService, ServerService } from '@affine/core/modules/cloud';
|
||||||
import { AuthService } from '@affine/core/modules/cloud';
|
|
||||||
import { WorkspacesService } from '@affine/core/modules/workspace';
|
import { WorkspacesService } from '@affine/core/modules/workspace';
|
||||||
import { UserFriendlyError } from '@affine/error';
|
import { UserFriendlyError } from '@affine/error';
|
||||||
import { Trans, useI18n } from '@affine/i18n';
|
import { Trans, useI18n } from '@affine/i18n';
|
||||||
import track from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useCallback, useState } from 'react';
|
import { combineLatest, map, of, switchMap } from 'rxjs';
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
import { RowLayout } from '../row.layout';
|
import { RowLayout } from '../row.layout';
|
||||||
import * as styles from './delete-account.css';
|
import * as styles from './delete-account.css';
|
||||||
|
|
||||||
export const DeleteAccount = () => {
|
export const DeleteAccount = ({
|
||||||
|
onDeleteFinished,
|
||||||
|
}: {
|
||||||
|
onDeleteFinished?: () => void;
|
||||||
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const workspacesService = useService(WorkspacesService);
|
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
const session = authService.session;
|
const workspacesService = useService(WorkspacesService);
|
||||||
const account = useLiveData(session.account$);
|
const account = useLiveData(authService.session.account$);
|
||||||
const workspaceProfiles = workspacesService.getAllWorkspaceProfile();
|
const isTeamWorkspaceOwner$ = useMemo(
|
||||||
const isTeamWorkspaceOwner = workspaceProfiles.some(
|
() =>
|
||||||
profile => profile.profile$.value?.isTeam && profile.profile$.value.isOwner
|
LiveData.from<boolean | null>(
|
||||||
);
|
workspacesService.list.workspaces$.pipe(
|
||||||
const [showModal, setShowModal] = useState(false);
|
switchMap(workspaces => {
|
||||||
|
if (!workspaces.length) {
|
||||||
|
return of(false);
|
||||||
|
}
|
||||||
|
|
||||||
const openModal = useCallback(() => {
|
return combineLatest(
|
||||||
setShowModal(true);
|
workspaces.map(meta => {
|
||||||
|
const profile = workspacesService.getProfile(meta);
|
||||||
|
profile.revalidate();
|
||||||
|
|
||||||
|
return combineLatest([
|
||||||
|
profile.profile$,
|
||||||
|
profile.isLoading$,
|
||||||
|
]).pipe(
|
||||||
|
map(([info, isLoading]) =>
|
||||||
|
isLoading && info === null
|
||||||
|
? null
|
||||||
|
: !!info?.isTeam && !!info?.isOwner
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
).pipe(
|
||||||
|
map(ownerStates => {
|
||||||
|
if (ownerStates.some(Boolean)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return ownerStates.some(state => state === null) ? null : false;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
),
|
||||||
|
null
|
||||||
|
),
|
||||||
|
[workspacesService]
|
||||||
|
);
|
||||||
|
const isTeamWorkspaceOwner = useLiveData(isTeamWorkspaceOwner$);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleOpen = useCallback(() => {
|
||||||
|
setOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (!account || isTeamWorkspaceOwner === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{account ? (
|
<RowLayout
|
||||||
<RowLayout
|
label={
|
||||||
label={t['com.affine.mobile.setting.others.delete-account']()}
|
<span className={styles.deleteAccountLabel}>
|
||||||
onClick={openModal}
|
{t['com.affine.mobile.setting.others.delete-account']()}
|
||||||
>
|
</span>
|
||||||
<ArrowRightSmallIcon fontSize={22} />
|
}
|
||||||
</RowLayout>
|
onClick={handleOpen}
|
||||||
) : null}
|
/>
|
||||||
{isTeamWorkspaceOwner ? (
|
{isTeamWorkspaceOwner ? (
|
||||||
<TeamOwnerWarningModal open={showModal} onOpenChange={setShowModal} />
|
<TeamOwnerWarningModal open={open} onOpenChange={setOpen} />
|
||||||
) : (
|
) : (
|
||||||
<DeleteAccountModal open={showModal} onOpenChange={setShowModal} />
|
<DeleteAccountModal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
onDeleteFinished={onDeleteFinished}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -56,9 +101,10 @@ const TeamOwnerWarningModal = ({
|
|||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const onConfirm = useCallback(() => {
|
const handleConfirm = useCallback(() => {
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
}, [onOpenChange]);
|
}, [onOpenChange]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={open}
|
open={open}
|
||||||
@@ -71,7 +117,7 @@ const TeamOwnerWarningModal = ({
|
|||||||
confirmButtonOptions={{
|
confirmButtonOptions={{
|
||||||
variant: 'primary',
|
variant: 'primary',
|
||||||
}}
|
}}
|
||||||
onConfirm={onConfirm}
|
onConfirm={handleConfirm}
|
||||||
cancelButtonOptions={{
|
cancelButtonOptions={{
|
||||||
style: {
|
style: {
|
||||||
display: 'none',
|
display: 'none',
|
||||||
@@ -84,50 +130,34 @@ const TeamOwnerWarningModal = ({
|
|||||||
const DeleteAccountModal = ({
|
const DeleteAccountModal = ({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
onDeleteFinished,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onDeleteFinished?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
const session = authService.session;
|
const serverService = useService(ServerService);
|
||||||
const account = useLiveData(session.account$);
|
const account = useLiveData(authService.session.account$);
|
||||||
|
const [phase, setPhase] = useState<'warning' | 'confirm'>('warning');
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const { openConfirmModal } = useConfirmModal();
|
|
||||||
const navigate = useNavigate();
|
useEffect(() => {
|
||||||
const onConfirm = useCallback(() => {
|
if (!open) {
|
||||||
navigate('/');
|
setPhase('warning');
|
||||||
}, [navigate]);
|
setEmail('');
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const handleDeleteAccount = useCallback(async () => {
|
const handleDeleteAccount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
await authService.deleteAccount();
|
await authService.deleteAccount();
|
||||||
track.$.$.auth.deleteAccount();
|
track.$.$.auth.deleteAccount();
|
||||||
openConfirmModal({
|
onDeleteFinished?.();
|
||||||
title: t['com.affine.setting.account.delete.success-title'](),
|
|
||||||
description: (
|
|
||||||
<>
|
|
||||||
<span>
|
|
||||||
{t['com.affine.setting.account.delete.success-description-1']()}
|
|
||||||
</span>
|
|
||||||
<br />
|
|
||||||
<span>
|
|
||||||
{t['com.affine.setting.account.delete.success-description-2']()}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
cancelButtonOptions: {
|
|
||||||
style: {
|
|
||||||
display: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
confirmText: t['Confirm'](),
|
|
||||||
onConfirm,
|
|
||||||
confirmButtonOptions: {
|
|
||||||
variant: 'primary',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
const error = UserFriendlyError.fromAny(err);
|
const error = UserFriendlyError.fromAny(err);
|
||||||
@@ -135,46 +165,113 @@ const DeleteAccountModal = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, [authService, onConfirm, openConfirmModal, t]);
|
}, [authService, onDeleteFinished]);
|
||||||
|
|
||||||
const onDeleteAccountConfirm = useAsyncCallback(async () => {
|
const handleDeleteAccountClick = useCallback(() => {
|
||||||
await handleDeleteAccount();
|
handleDeleteAccount().catch(console.error);
|
||||||
}, [handleDeleteAccount]);
|
}, [handleDeleteAccount]);
|
||||||
|
|
||||||
const onCancel = useCallback(() => {
|
|
||||||
onOpenChange(false);
|
|
||||||
}, [onOpenChange]);
|
|
||||||
|
|
||||||
if (!account) {
|
if (!account) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfirmModal
|
<>
|
||||||
open={open}
|
<ConfirmModal
|
||||||
onOpenChange={onOpenChange}
|
open={open && phase === 'warning'}
|
||||||
title={t['com.affine.setting.account.delete.confirm-title']()}
|
onOpenChange={nextOpen => {
|
||||||
description={
|
if (!nextOpen) {
|
||||||
<Trans
|
onOpenChange(false);
|
||||||
i18nKey="com.affine.setting.account.delete.confirm-description-2"
|
}
|
||||||
components={{
|
}}
|
||||||
1: <strong />,
|
title={t['com.affine.setting.account.delete.confirm-title']()}
|
||||||
}}
|
description={
|
||||||
|
<>
|
||||||
|
<Trans
|
||||||
|
i18nKey="com.affine.setting.account.delete.confirm-delete-description-1"
|
||||||
|
components={{
|
||||||
|
1: <strong />,
|
||||||
|
}}
|
||||||
|
values={{
|
||||||
|
server:
|
||||||
|
serverService.server.id !== 'affine-cloud'
|
||||||
|
? `${serverService.server.config$.value.serverName} (${serverService.server.baseUrl})`
|
||||||
|
: serverService.server.config$.value.serverName,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<Trans
|
||||||
|
i18nKey="com.affine.setting.account.delete.confirm-delete-description-2"
|
||||||
|
components={{
|
||||||
|
1: <strong />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
descriptionClassName={styles.description}
|
||||||
|
confirmText={t['Continue']()}
|
||||||
|
confirmButtonOptions={{
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: () => {
|
||||||
|
setPhase('confirm');
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
cancelText={t['Cancel']()}
|
||||||
|
cancelButtonOptions={{
|
||||||
|
variant: 'primary',
|
||||||
|
}}
|
||||||
|
rowFooter
|
||||||
|
/>
|
||||||
|
<ConfirmModal
|
||||||
|
open={open && phase === 'confirm'}
|
||||||
|
onOpenChange={nextOpen => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
onOpenChange(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t['com.affine.setting.account.delete.email-confirm-title']()}
|
||||||
|
description={
|
||||||
|
<Trans
|
||||||
|
i18nKey="com.affine.setting.account.delete.email-confirm-description"
|
||||||
|
components={{
|
||||||
|
1: <strong />,
|
||||||
|
}}
|
||||||
|
values={{
|
||||||
|
email: account.email,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
descriptionClassName={styles.description}
|
||||||
|
confirmText={t['com.affine.setting.account.delete.confirm-button']()}
|
||||||
|
confirmButtonOptions={{
|
||||||
|
variant: 'error',
|
||||||
|
disabled:
|
||||||
|
email.trim().toLowerCase() !==
|
||||||
|
account.email?.trim().toLowerCase() || isLoading,
|
||||||
|
loading: isLoading,
|
||||||
|
onClick: handleDeleteAccountClick,
|
||||||
|
}}
|
||||||
|
cancelText={t['Cancel']()}
|
||||||
|
cancelButtonOptions={{
|
||||||
|
variant: 'primary',
|
||||||
|
onClick: event => {
|
||||||
|
event.preventDefault();
|
||||||
|
setPhase('warning');
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
rowFooter
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
placeholder={t[
|
||||||
|
'com.affine.setting.account.delete.input-placeholder'
|
||||||
|
]()}
|
||||||
|
value={email}
|
||||||
|
onChange={setEmail}
|
||||||
|
className={styles.inputWrapper}
|
||||||
/>
|
/>
|
||||||
}
|
</ConfirmModal>
|
||||||
descriptionClassName={styles.description}
|
</>
|
||||||
confirmText={t['com.affine.setting.account.delete.confirm-button']()}
|
|
||||||
confirmButtonOptions={{
|
|
||||||
variant: 'error',
|
|
||||||
disabled: isLoading,
|
|
||||||
loading: isLoading,
|
|
||||||
onClick: onDeleteAccountConfirm,
|
|
||||||
}}
|
|
||||||
onCancel={onCancel}
|
|
||||||
cancelText={t['Cancel']()}
|
|
||||||
cancelButtonOptions={{
|
|
||||||
variant: 'primary',
|
|
||||||
}}
|
|
||||||
rowFooter
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
|
||||||
import { keyframes, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
const shineAnimation = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(-100%)',
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(100%)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const hotTag = style({
|
|
||||||
background: cssVarV2('chip/tag/red'),
|
|
||||||
padding: '0px 8px',
|
|
||||||
borderRadius: 20,
|
|
||||||
lineHeight: '20px',
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: 500,
|
|
||||||
color: 'white',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
|
|
||||||
border: '0.5px solid red',
|
|
||||||
boxShadow: '0px 2px 3px rgba(0,0,0,0.1), 0px 0px 3px rgba(255,0,0, 0.5)',
|
|
||||||
|
|
||||||
vars: {
|
|
||||||
'--shine-color': 'rgba(255,255,255,0.5)',
|
|
||||||
},
|
|
||||||
|
|
||||||
selectors: {
|
|
||||||
'&::after': {
|
|
||||||
content: "''",
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
pointerEvents: 'none',
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
transform: 'translateX(-100%)',
|
|
||||||
animation: `${shineAnimation} 900ms ease-out`,
|
|
||||||
animationFillMode: 'forwards',
|
|
||||||
|
|
||||||
backgroundImage: `linear-gradient(90deg, transparent 0%, var(--shine-color) 50%, transparent 100%)`,
|
|
||||||
backgroundRepeat: 'no-repeat, no-repeat',
|
|
||||||
backgroundSize: '100% 100%',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'@media': {
|
|
||||||
'(prefers-reduced-motion: reduce)': {
|
|
||||||
selectors: {
|
|
||||||
'&::after': {
|
|
||||||
animation: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -2,8 +2,6 @@ import { useI18n } from '@affine/i18n';
|
|||||||
|
|
||||||
import { SettingGroup } from '../group';
|
import { SettingGroup } from '../group';
|
||||||
import { RowLayout } from '../row.layout';
|
import { RowLayout } from '../row.layout';
|
||||||
import { DeleteAccount } from './delete-account';
|
|
||||||
import { hotTag } from './index.css';
|
|
||||||
|
|
||||||
export const OthersGroup = () => {
|
export const OthersGroup = () => {
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -11,12 +9,7 @@ export const OthersGroup = () => {
|
|||||||
return (
|
return (
|
||||||
<SettingGroup title={t['com.affine.mobile.setting.others.title']()}>
|
<SettingGroup title={t['com.affine.mobile.setting.others.title']()}>
|
||||||
<RowLayout
|
<RowLayout
|
||||||
label={
|
label={t['com.affine.mobile.setting.others.discord']()}
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
||||||
{t['com.affine.mobile.setting.others.discord']()}
|
|
||||||
<div className={hotTag}>Hot</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
href="https://discord.com/invite/whd5mjYqVw"
|
href="https://discord.com/invite/whd5mjYqVw"
|
||||||
/>
|
/>
|
||||||
<RowLayout
|
<RowLayout
|
||||||
@@ -38,7 +31,6 @@ export const OthersGroup = () => {
|
|||||||
label={t['com.affine.mobile.setting.others.terms']()}
|
label={t['com.affine.mobile.setting.others.terms']()}
|
||||||
href="https://affine.pro/terms"
|
href="https://affine.pro/terms"
|
||||||
/>
|
/>
|
||||||
<DeleteAccount />
|
|
||||||
</SettingGroup>
|
</SettingGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ConfigModal } from '@affine/core/components/mobile';
|
import { ConfigModal } from '@affine/core/components/mobile';
|
||||||
import { DualLinkIcon } from '@blocksuite/icons/rc';
|
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||||
import type { PropsWithChildren, ReactNode } from 'react';
|
import clsx from 'clsx';
|
||||||
|
import type { KeyboardEvent, PropsWithChildren, ReactNode } from 'react';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
@@ -14,25 +16,65 @@ export const RowLayout = ({
|
|||||||
href?: string;
|
href?: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}>) => {
|
}>) => {
|
||||||
const content = (
|
const isLinkRow = !!href && !onClick;
|
||||||
<ConfigModal.Row
|
const isButtonRow = !!onClick;
|
||||||
data-testid="setting-row"
|
const isInteractive = isLinkRow || isButtonRow;
|
||||||
className={styles.baseSettingItem}
|
|
||||||
onClick={onClick}
|
const handleTrigger = useCallback(() => {
|
||||||
>
|
onClick?.();
|
||||||
<div className={styles.baseSettingItemName}>{label}</div>
|
}, [onClick]);
|
||||||
<div className={styles.baseSettingItemAction}>
|
|
||||||
{children ||
|
const handleKeyDown = useCallback(
|
||||||
(href ? <DualLinkIcon className={styles.linkIcon} /> : null)}
|
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||||
</div>
|
if (!isButtonRow) {
|
||||||
</ConfigModal.Row>
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
handleTrigger();
|
||||||
|
},
|
||||||
|
[handleTrigger, isButtonRow]
|
||||||
);
|
);
|
||||||
|
|
||||||
return href ? (
|
const content = (
|
||||||
<a target="_blank" href={href} rel="noreferrer">
|
<>
|
||||||
{content}
|
<div className={styles.baseSettingItemName}>{label}</div>
|
||||||
</a>
|
<div className={styles.baseSettingItemAction}>
|
||||||
) : (
|
{children ??
|
||||||
content
|
(isInteractive ? (
|
||||||
|
<ArrowRightSmallIcon className={styles.linkIcon} />
|
||||||
|
) : null)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigModal.Row
|
||||||
|
data-testid="setting-row"
|
||||||
|
className={clsx(styles.baseSettingItem, {
|
||||||
|
[styles.interactiveRow]: isInteractive,
|
||||||
|
})}
|
||||||
|
onClick={isButtonRow ? handleTrigger : undefined}
|
||||||
|
onKeyDown={isButtonRow ? handleKeyDown : undefined}
|
||||||
|
role={isButtonRow ? 'button' : undefined}
|
||||||
|
tabIndex={isButtonRow ? 0 : undefined}
|
||||||
|
>
|
||||||
|
{isLinkRow ? (
|
||||||
|
<a
|
||||||
|
className={styles.linkRowContent}
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
content
|
||||||
|
)}
|
||||||
|
</ConfigModal.Row>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,46 +2,190 @@ import { bodyEmphasized, bodyRegular } from '@toeverything/theme/typography';
|
|||||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const pageTitle = style([bodyEmphasized]);
|
export const pageTitle = style([
|
||||||
|
bodyEmphasized,
|
||||||
|
{
|
||||||
|
fontSize: 19,
|
||||||
|
lineHeight: '24px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const root = style({
|
export const root = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 16,
|
gap: 22,
|
||||||
padding: '24px 16px',
|
paddingTop: 0,
|
||||||
|
paddingRight: 16,
|
||||||
|
paddingBottom: 'calc(env(safe-area-inset-bottom) + 20px)',
|
||||||
|
paddingLeft: 16,
|
||||||
|
boxSizing: 'border-box',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const baseSettingItem = style({
|
export const baseSettingItem = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 32,
|
gap: 12,
|
||||||
padding: 8,
|
width: '100%',
|
||||||
|
minHeight: 44,
|
||||||
|
padding: '10px 14px',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
selectors: {
|
||||||
|
'&:not(:last-child)': {
|
||||||
|
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const interactiveRow = style({
|
||||||
|
cursor: 'pointer',
|
||||||
|
transition: 'background-color 160ms ease',
|
||||||
|
selectors: {
|
||||||
|
'&:active': {
|
||||||
|
background: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const baseSettingItemName = style([
|
export const baseSettingItemName = style([
|
||||||
bodyRegular,
|
bodyRegular,
|
||||||
{
|
{
|
||||||
color: cssVarV2('text/primary'),
|
color: cssVarV2('text/primary'),
|
||||||
flexShrink: 0,
|
minWidth: 0,
|
||||||
whiteSpace: 'nowrap',
|
flex: 1,
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: '22px',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const baseSettingItemAction = style([
|
export const baseSettingItemAction = style([
|
||||||
baseSettingItemName,
|
bodyRegular,
|
||||||
{
|
{
|
||||||
color: cssVarV2('text/placeholder'),
|
color: cssVarV2('text/placeholder'),
|
||||||
whiteSpace: 'nowrap',
|
marginLeft: 12,
|
||||||
textOverflow: 'ellipsis',
|
minWidth: 0,
|
||||||
overflow: 'hidden',
|
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: 6,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: 17,
|
||||||
|
lineHeight: '22px',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const linkIcon = style({
|
export const linkRowContent = style({
|
||||||
fontSize: 24,
|
display: 'flex',
|
||||||
color: cssVarV2('icon/primary'),
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 12,
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
color: 'inherit',
|
||||||
|
textDecoration: 'none',
|
||||||
|
selectors: {
|
||||||
|
'&:visited': {
|
||||||
|
color: 'inherit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const linkIcon = style({
|
||||||
|
fontSize: 17,
|
||||||
|
color: cssVarV2('icon/secondary'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promoCard = style({
|
||||||
|
position: 'relative',
|
||||||
|
overflow: 'hidden',
|
||||||
|
border: '0.5px solid rgba(255,255,255,0.14)',
|
||||||
|
borderRadius: 30,
|
||||||
|
padding: '16px 20px 14px',
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 116,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
textAlign: 'left',
|
||||||
|
backgroundColor: cssVarV2('button/primary'),
|
||||||
|
backgroundImage:
|
||||||
|
'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0.04) 34%, rgba(255,255,255,0.02) 100%)',
|
||||||
|
color: cssVarV2('button/pureWhiteText'),
|
||||||
|
cursor: 'pointer',
|
||||||
|
isolation: 'isolate',
|
||||||
|
transition: 'transform 180ms ease, box-shadow 180ms ease',
|
||||||
|
boxShadow:
|
||||||
|
'0 10px 20px rgba(13, 40, 99, 0.12), inset 0 1px 0 rgba(255,255,255,0.12)',
|
||||||
|
selectors: {
|
||||||
|
'&::before': {
|
||||||
|
content: '""',
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
background:
|
||||||
|
'linear-gradient(180deg, rgba(255,255,255,0.10) 0%, rgba(255,255,255,0) 48%)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 0,
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
transform: 'scale(0.995)',
|
||||||
|
boxShadow:
|
||||||
|
'0 6px 12px rgba(13, 40, 99, 0.1), inset 0 1px 0 rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promoCardContent = style({
|
||||||
|
position: 'relative',
|
||||||
|
zIndex: 2,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 6,
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 'none',
|
||||||
|
paddingRight: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promoCardTitle = style({
|
||||||
|
display: 'block',
|
||||||
|
paddingRight: 72,
|
||||||
|
fontSize: 20,
|
||||||
|
lineHeight: '26px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: cssVarV2('button/pureWhiteText'),
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
textShadow: '0 0.5px 1px rgba(7, 48, 121, 0.12)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promoCardDescription = style({
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
maxWidth: 'none',
|
||||||
|
paddingRight: 96,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: '21px',
|
||||||
|
color: cssVarV2('button/pureWhiteText'),
|
||||||
|
opacity: 0.94,
|
||||||
|
textShadow: '0 0.5px 1px rgba(7, 48, 121, 0.08)',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const promoCardArt = style({
|
||||||
|
position: 'absolute',
|
||||||
|
right: 14,
|
||||||
|
bottom: 8,
|
||||||
|
width: 80,
|
||||||
|
height: 'auto',
|
||||||
|
objectFit: 'contain',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 1,
|
||||||
|
filter: 'drop-shadow(0 6px 12px rgba(7, 48, 121, 0.12))',
|
||||||
|
opacity: 0.9,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dangerZoneTitle = style({
|
||||||
|
color: cssVarV2('status/error'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Button } from '@affine/component';
|
import { Button } from '@affine/component';
|
||||||
import { AuthService, ServerService } from '@affine/core/modules/cloud';
|
import { AuthService, ServerService } from '@affine/core/modules/cloud';
|
||||||
|
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||||
import { NativePaywallService } from '@affine/core/modules/paywall';
|
import { NativePaywallService } from '@affine/core/modules/paywall';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import proDiamond from '../assets/pro-diamond.png';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
export const UserSubscription = () => {
|
export const UserSubscription = () => {
|
||||||
const serverService = useService(ServerService);
|
const serverService = useService(ServerService);
|
||||||
const authService = useService(AuthService);
|
const authService = useService(AuthService);
|
||||||
|
const globalDialogService = useService(GlobalDialogService);
|
||||||
const nativePaywallProvider =
|
const nativePaywallProvider =
|
||||||
useService(NativePaywallService).getNativePaywallProvider();
|
useService(NativePaywallService).getNativePaywallProvider();
|
||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
@@ -16,40 +20,40 @@ export const UserSubscription = () => {
|
|||||||
const supported = useLiveData(
|
const supported = useLiveData(
|
||||||
serverService.server.features$.map(f => f.payment)
|
serverService.server.features$.map(f => f.payment)
|
||||||
);
|
);
|
||||||
|
|
||||||
const loggedIn = useLiveData(authService.session.status$) === 'authenticated';
|
const loggedIn = useLiveData(authService.session.status$) === 'authenticated';
|
||||||
|
|
||||||
if (!loggedIn) {
|
const handleOpen = useCallback(() => {
|
||||||
return null;
|
if (!loggedIn) {
|
||||||
}
|
globalDialogService.open('sign-in', {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!supported) {
|
void nativePaywallProvider?.showPaywall('Pro').catch(console.error);
|
||||||
// TODO: enable this
|
}, [globalDialogService, loggedIn, nativePaywallProvider]);
|
||||||
// return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nativePaywallProvider) {
|
if (!nativePaywallProvider || supported === false) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
<div className={styles.title}>
|
<div className={styles.headerRow}>
|
||||||
{t['com.affine.payment.subscription.title']()}
|
<div className={styles.perkIconWrapper}>
|
||||||
</div>
|
<img className={styles.perkIcon} src={proDiamond} alt="" />
|
||||||
<div className={styles.description}>
|
</div>
|
||||||
{t['com.affine.payment.subscription.description']()}
|
<div className={styles.textBlock}>
|
||||||
|
<div className={styles.title}>
|
||||||
|
{t['com.affine.mobile.setting.subscription.title']()}
|
||||||
|
</div>
|
||||||
|
<div className={styles.description}>
|
||||||
|
{t['com.affine.mobile.setting.subscription.description']()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button className={styles.button} variant="primary" onClick={handleOpen}>
|
||||||
className={styles.button}
|
{t['com.affine.mobile.setting.subscription.button']()}
|
||||||
variant="primary"
|
|
||||||
onClick={() =>
|
|
||||||
void nativePaywallProvider.showPaywall('Pro').catch(console.error)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t['com.affine.payment.subscription.button']()}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,35 +3,74 @@ import { style } from '@vanilla-extract/css';
|
|||||||
|
|
||||||
export const root = style({
|
export const root = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'row',
|
flexDirection: 'column',
|
||||||
alignItems: 'center',
|
gap: 18,
|
||||||
gap: 16,
|
borderRadius: 24,
|
||||||
border: `1px solid ${cssVarV2('database/border')}`,
|
padding: '24px',
|
||||||
borderRadius: '12px',
|
backgroundColor: cssVarV2('layer/background/primary'),
|
||||||
padding: '10px 16px',
|
boxSizing: 'border-box',
|
||||||
backgroundColor: cssVarV2('edgeless/selection/selectionMarqueeBackground'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const content = style({
|
export const content = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
gap: 4,
|
alignItems: 'stretch',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const headerRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 16,
|
||||||
|
width: '100%',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const perkIconWrapper = style({
|
||||||
|
width: 42,
|
||||||
|
height: 42,
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flexShrink: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const textBlock = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
minWidth: 0,
|
||||||
|
flex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const title = style({
|
export const title = style({
|
||||||
fontSize: '17px',
|
fontSize: '18px',
|
||||||
lineHeight: '22px',
|
lineHeight: '22px',
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
color: cssVarV2('text/primary'),
|
color: cssVarV2('text/primary'),
|
||||||
|
textAlign: 'left',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const perkIcon = style({
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
flexShrink: 0,
|
||||||
|
objectFit: 'contain',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const description = style({
|
export const description = style({
|
||||||
fontSize: '13px',
|
fontSize: '14px',
|
||||||
lineHeight: '18px',
|
lineHeight: '19px',
|
||||||
fontWeight: 400,
|
fontWeight: 400,
|
||||||
color: cssVarV2('text/secondary'),
|
color: cssVarV2('text/secondary'),
|
||||||
|
maxWidth: 250,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const button = style({
|
export const button = style({
|
||||||
|
width: '100%',
|
||||||
|
minHeight: 48,
|
||||||
fontSize: '15px',
|
fontSize: '15px',
|
||||||
|
fontWeight: 600,
|
||||||
|
borderRadius: 999,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { type ReactNode } from 'react';
|
|||||||
|
|
||||||
import { UserPlanTag } from '../../../components';
|
import { UserPlanTag } from '../../../components';
|
||||||
import { SettingGroup } from '../group';
|
import { SettingGroup } from '../group';
|
||||||
|
import { RowLayout } from '../row.layout';
|
||||||
import * as styles from './style.css';
|
import * as styles from './style.css';
|
||||||
|
|
||||||
export const UserProfile = () => {
|
export const UserProfile = () => {
|
||||||
@@ -26,15 +27,17 @@ const BaseLayout = ({
|
|||||||
avatar,
|
avatar,
|
||||||
title,
|
title,
|
||||||
caption,
|
caption,
|
||||||
|
sectionTitle,
|
||||||
onClick,
|
onClick,
|
||||||
}: {
|
}: {
|
||||||
avatar: ReactNode;
|
avatar: ReactNode;
|
||||||
title: ReactNode;
|
title: ReactNode;
|
||||||
caption: ReactNode;
|
caption: ReactNode;
|
||||||
|
sectionTitle: string;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<SettingGroup contentStyle={{ padding: '10px 8px 10px 10px' }}>
|
<SettingGroup title={sectionTitle} contentStyle={{ padding: '12px 14px' }}>
|
||||||
<div className={styles.profile} onClick={onClick}>
|
<div className={styles.profile} onClick={onClick}>
|
||||||
<div className={styles.avatarWrapper}>{avatar}</div>
|
<div className={styles.avatarWrapper}>{avatar}</div>
|
||||||
<div className={styles.content}>
|
<div className={styles.content}>
|
||||||
@@ -51,9 +54,11 @@ const AuthorizedUserProfile = () => {
|
|||||||
const session = useService(AuthService).session;
|
const session = useService(AuthService).session;
|
||||||
const account = useLiveData(session.account$);
|
const account = useLiveData(session.account$);
|
||||||
const confirmSignOut = useSignOut();
|
const confirmSignOut = useSignOut();
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout
|
<BaseLayout
|
||||||
|
sectionTitle={t['com.affine.mobile.setting.account.title']()}
|
||||||
avatar={
|
avatar={
|
||||||
<Avatar
|
<Avatar
|
||||||
size={48}
|
size={48}
|
||||||
@@ -75,15 +80,15 @@ const AuthorizedUserProfile = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const UnauthorizedUserProfile = () => {
|
const UnauthorizedUserProfile = () => {
|
||||||
const { t } = useI18n();
|
const t = useI18n();
|
||||||
const globalDialogService = useService(GlobalDialogService);
|
const globalDialogService = useService(GlobalDialogService);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseLayout
|
<SettingGroup title={t['com.affine.mobile.setting.account.title']()}>
|
||||||
onClick={() => globalDialogService.open('sign-in', {})}
|
<RowLayout
|
||||||
avatar={<Avatar size={48} rounded={4} />}
|
label={t['com.affine.mobile.setting.account.sign-in']()}
|
||||||
title={t(`com.affine.settings.sign`)}
|
onClick={() => globalDialogService.open('sign-in', {})}
|
||||||
caption={t(`com.affine.setting.sign.message`)}
|
/>
|
||||||
/>
|
</SettingGroup>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,16 +33,27 @@ const ellipsis = style({
|
|||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const title = style([bodyRegular, { color: cssVarV2('text/primary') }]);
|
export const title = style([
|
||||||
|
bodyRegular,
|
||||||
|
{
|
||||||
|
color: cssVarV2('text/primary'),
|
||||||
|
fontSize: 19,
|
||||||
|
lineHeight: '24px',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const caption = style([
|
export const caption = style([
|
||||||
subHeadlineRegular,
|
subHeadlineRegular,
|
||||||
{ color: cssVarV2('text/secondary') },
|
{
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: '20px',
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const suffixIcon = style({
|
export const suffixIcon = style({
|
||||||
fontSize: 30,
|
fontSize: 20,
|
||||||
color: cssVarV2('icon/primary'),
|
color: cssVarV2('icon/secondary'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const emailInfo = style([ellipsis, { width: '100%' }]);
|
export const emailInfo = style([ellipsis, { width: '100%' }]);
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
{
|
{
|
||||||
"ar": 89,
|
"ar": 88,
|
||||||
"ca": 86,
|
"ca": 85,
|
||||||
"da": 3,
|
"da": 3,
|
||||||
"de": 96,
|
"de": 96,
|
||||||
"el-GR": 85,
|
"el-GR": 84,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 85,
|
"es-AR": 84,
|
||||||
"es-CL": 86,
|
"es-CL": 85,
|
||||||
"es": 85,
|
"es": 84,
|
||||||
"fa": 85,
|
"fa": 84,
|
||||||
"fr": 89,
|
"fr": 88,
|
||||||
"hi": 1,
|
"hi": 1,
|
||||||
"it": 86,
|
"it": 86,
|
||||||
"ja": 85,
|
"ja": 84,
|
||||||
"kk": 92,
|
"kk": 91,
|
||||||
"ko": 86,
|
"ko": 85,
|
||||||
"nb-NO": 42,
|
"nb-NO": 42,
|
||||||
"pl": 86,
|
"pl": 86,
|
||||||
"pt-BR": 85,
|
"pt-BR": 84,
|
||||||
"ru": 87,
|
"ru": 86,
|
||||||
"sv-SE": 85,
|
"sv-SE": 85,
|
||||||
"tr": 92,
|
"tr": 91,
|
||||||
"uk": 85,
|
"uk": 84,
|
||||||
"ur": 92,
|
"ur": 91,
|
||||||
"zh-Hans": 96,
|
"zh-Hans": 95,
|
||||||
"zh-Hant": 87
|
"zh-Hant": 87
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3035,6 +3035,14 @@ export function useAFFiNEI18N(): {
|
|||||||
* `About`
|
* `About`
|
||||||
*/
|
*/
|
||||||
["com.affine.mobile.setting.about.title"](): string;
|
["com.affine.mobile.setting.about.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Log In / Sign Up`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.account.sign-in"](): string;
|
||||||
|
/**
|
||||||
|
* `Account & Data`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.account.title"](): string;
|
||||||
/**
|
/**
|
||||||
* `Font style`
|
* `Font style`
|
||||||
*/
|
*/
|
||||||
@@ -3051,6 +3059,14 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Appearance`
|
* `Appearance`
|
||||||
*/
|
*/
|
||||||
["com.affine.mobile.setting.appearance.title"](): string;
|
["com.affine.mobile.setting.appearance.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Experimental features`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.experimental.features"](): string;
|
||||||
|
/**
|
||||||
|
* `Experimental`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.experimental.title"](): string;
|
||||||
/**
|
/**
|
||||||
* `Settings`
|
* `Settings`
|
||||||
*/
|
*/
|
||||||
@@ -3060,7 +3076,7 @@ export function useAFFiNEI18N(): {
|
|||||||
*/
|
*/
|
||||||
["com.affine.mobile.setting.others.github"](): string;
|
["com.affine.mobile.setting.others.github"](): string;
|
||||||
/**
|
/**
|
||||||
* `Discord Group`
|
* `Discord group`
|
||||||
*/
|
*/
|
||||||
["com.affine.mobile.setting.others.discord"](): string;
|
["com.affine.mobile.setting.others.discord"](): string;
|
||||||
/**
|
/**
|
||||||
@@ -3080,9 +3096,49 @@ export function useAFFiNEI18N(): {
|
|||||||
*/
|
*/
|
||||||
["com.affine.mobile.setting.others.website"](): string;
|
["com.affine.mobile.setting.others.website"](): string;
|
||||||
/**
|
/**
|
||||||
* `Delete my account`
|
* `Delete Account`
|
||||||
*/
|
*/
|
||||||
["com.affine.mobile.setting.others.delete-account"](): string;
|
["com.affine.mobile.setting.others.delete-account"](): string;
|
||||||
|
/**
|
||||||
|
* `Danger Zone`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.danger-zone.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Collaborate seamlessly with AFFiNE team, available in Cloud and Self-Hosted versions.`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.promo.description"](): string;
|
||||||
|
/**
|
||||||
|
* `AFFiNE for team and more`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.promo.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Go Pro`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.subscription.button"](): string;
|
||||||
|
/**
|
||||||
|
* `Unlimited space for your notes and boards.`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.subscription.description"](): string;
|
||||||
|
/**
|
||||||
|
* `Unlock Pro Features`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.subscription.title"](): string;
|
||||||
|
/**
|
||||||
|
* `Invite a friend`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.support.invite"](): string;
|
||||||
|
/**
|
||||||
|
* `Check out AFFiNE for notes, whiteboards, docs, and AI.`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.support.invite-message"](): string;
|
||||||
|
/**
|
||||||
|
* `Rate AFFiNE`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.support.rate"](): string;
|
||||||
|
/**
|
||||||
|
* `Support us`
|
||||||
|
*/
|
||||||
|
["com.affine.mobile.setting.support.title"](): string;
|
||||||
/**
|
/**
|
||||||
* `Want to keep data local?`
|
* `Want to keep data local?`
|
||||||
*/
|
*/
|
||||||
@@ -5242,6 +5298,10 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Delete your account?`
|
* `Delete your account?`
|
||||||
*/
|
*/
|
||||||
["com.affine.setting.account.delete.confirm-title"](): string;
|
["com.affine.setting.account.delete.confirm-title"](): string;
|
||||||
|
/**
|
||||||
|
* `Confirm your email`
|
||||||
|
*/
|
||||||
|
["com.affine.setting.account.delete.email-confirm-title"](): string;
|
||||||
/**
|
/**
|
||||||
* `Please type your email to confirm`
|
* `Please type your email to confirm`
|
||||||
*/
|
*/
|
||||||
@@ -10759,6 +10819,14 @@ export const TypedTrans: {
|
|||||||
["com.affine.setting.account.delete.confirm-delete-description-2"]: ComponentType<TypedTransProps<Readonly<{}>, {
|
["com.affine.setting.account.delete.confirm-delete-description-2"]: ComponentType<TypedTransProps<Readonly<{}>, {
|
||||||
["1"]: JSX.Element;
|
["1"]: JSX.Element;
|
||||||
}>>;
|
}>>;
|
||||||
|
/**
|
||||||
|
* `Type <1>{{email}}</1> to confirm account deletion.`
|
||||||
|
*/
|
||||||
|
["com.affine.setting.account.delete.email-confirm-description"]: ComponentType<TypedTransProps<{
|
||||||
|
readonly email: string;
|
||||||
|
}, {
|
||||||
|
["1"]: JSX.Element;
|
||||||
|
}>>;
|
||||||
/**
|
/**
|
||||||
* `Don't have the app? <1>Click to download</1>.`
|
* `Don't have the app? <1>Click to download</1>.`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -750,18 +750,32 @@
|
|||||||
"com.affine.mobile.setting.about.appVersion": "App version",
|
"com.affine.mobile.setting.about.appVersion": "App version",
|
||||||
"com.affine.mobile.setting.about.editorVersion": "Editor version",
|
"com.affine.mobile.setting.about.editorVersion": "Editor version",
|
||||||
"com.affine.mobile.setting.about.title": "About",
|
"com.affine.mobile.setting.about.title": "About",
|
||||||
|
"com.affine.mobile.setting.account.sign-in": "Log In / Sign Up",
|
||||||
|
"com.affine.mobile.setting.account.title": "Account & Data",
|
||||||
"com.affine.mobile.setting.appearance.font": "Font style",
|
"com.affine.mobile.setting.appearance.font": "Font style",
|
||||||
"com.affine.mobile.setting.appearance.language": "Display language",
|
"com.affine.mobile.setting.appearance.language": "Display language",
|
||||||
"com.affine.mobile.setting.appearance.theme": "Color mode",
|
"com.affine.mobile.setting.appearance.theme": "Color mode",
|
||||||
"com.affine.mobile.setting.appearance.title": "Appearance",
|
"com.affine.mobile.setting.appearance.title": "Appearance",
|
||||||
|
"com.affine.mobile.setting.experimental.features": "Experimental features",
|
||||||
|
"com.affine.mobile.setting.experimental.title": "Experimental",
|
||||||
"com.affine.mobile.setting.header-title": "Settings",
|
"com.affine.mobile.setting.header-title": "Settings",
|
||||||
"com.affine.mobile.setting.others.github": "Star us on GitHub",
|
"com.affine.mobile.setting.others.github": "Star us on GitHub",
|
||||||
"com.affine.mobile.setting.others.discord": "Discord Group",
|
"com.affine.mobile.setting.others.discord": "Discord group",
|
||||||
"com.affine.mobile.setting.others.privacy": "Privacy",
|
"com.affine.mobile.setting.others.privacy": "Privacy",
|
||||||
"com.affine.mobile.setting.others.terms": "Terms of use",
|
"com.affine.mobile.setting.others.terms": "Terms of use",
|
||||||
"com.affine.mobile.setting.others.title": "Privacy & others",
|
"com.affine.mobile.setting.others.title": "Privacy & others",
|
||||||
"com.affine.mobile.setting.others.website": "Official website",
|
"com.affine.mobile.setting.others.website": "Official website",
|
||||||
"com.affine.mobile.setting.others.delete-account": "Delete my account",
|
"com.affine.mobile.setting.others.delete-account": "Delete Account",
|
||||||
|
"com.affine.mobile.setting.danger-zone.title": "Danger Zone",
|
||||||
|
"com.affine.mobile.setting.promo.description": "Collaborate seamlessly with AFFiNE team, available in Cloud and Self-Hosted versions.",
|
||||||
|
"com.affine.mobile.setting.promo.title": "AFFiNE for team and more",
|
||||||
|
"com.affine.mobile.setting.subscription.button": "Go Pro",
|
||||||
|
"com.affine.mobile.setting.subscription.description": "Unlimited space for your notes and boards.",
|
||||||
|
"com.affine.mobile.setting.subscription.title": "Unlock Pro Features",
|
||||||
|
"com.affine.mobile.setting.support.invite": "Invite a friend",
|
||||||
|
"com.affine.mobile.setting.support.invite-message": "Check out AFFiNE for notes, whiteboards, docs, and AI.",
|
||||||
|
"com.affine.mobile.setting.support.rate": "Rate AFFiNE",
|
||||||
|
"com.affine.mobile.setting.support.title": "Support us",
|
||||||
"com.affine.mobile.sign-in.skip.hint": "Want to keep data local?",
|
"com.affine.mobile.sign-in.skip.hint": "Want to keep data local?",
|
||||||
"com.affine.mobile.sign-in.skip.link": "Start AFFiNE without an account",
|
"com.affine.mobile.sign-in.skip.link": "Start AFFiNE without an account",
|
||||||
"com.affine.moreThan30Days": "Older than a month",
|
"com.affine.moreThan30Days": "Older than a month",
|
||||||
@@ -1297,6 +1311,8 @@
|
|||||||
"com.affine.setting.account.delete.confirm-title": "Delete your account?",
|
"com.affine.setting.account.delete.confirm-title": "Delete your account?",
|
||||||
"com.affine.setting.account.delete.confirm-delete-description-1": "Are you sure you want to delete your account from <1>{{server}}</1>?",
|
"com.affine.setting.account.delete.confirm-delete-description-1": "Are you sure you want to delete your account from <1>{{server}}</1>?",
|
||||||
"com.affine.setting.account.delete.confirm-delete-description-2": "Your account will be inaccessible, and your personal space on the server will be permanently deleted. You can remove local data by uninstalling the app or clearing your browser storage. <1>This action is irreversible.</1>",
|
"com.affine.setting.account.delete.confirm-delete-description-2": "Your account will be inaccessible, and your personal space on the server will be permanently deleted. You can remove local data by uninstalling the app or clearing your browser storage. <1>This action is irreversible.</1>",
|
||||||
|
"com.affine.setting.account.delete.email-confirm-title": "Confirm your email",
|
||||||
|
"com.affine.setting.account.delete.email-confirm-description": "Type <1>{{email}}</1> to confirm account deletion.",
|
||||||
"com.affine.setting.account.delete.input-placeholder": "Please type your email to confirm",
|
"com.affine.setting.account.delete.input-placeholder": "Please type your email to confirm",
|
||||||
"com.affine.setting.account.delete.confirm-button": "Delete",
|
"com.affine.setting.account.delete.confirm-button": "Delete",
|
||||||
"com.affine.setting.account.delete.success-title": "Account deleted",
|
"com.affine.setting.account.delete.success-title": "Account deleted",
|
||||||
|
|||||||
@@ -16,9 +16,8 @@ test('after loaded, will land on the home page', async ({ page }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('stale first-open state still restores one local workspace', async ({
|
test('stale first-open state still restores one local workspace', async ({
|
||||||
browser,
|
context,
|
||||||
}) => {
|
}) => {
|
||||||
const context = await browser.newContext();
|
|
||||||
await context.addInitScript(() => {
|
await context.addInitScript(() => {
|
||||||
window.localStorage.setItem('app_config', '{"onBoarding":false}');
|
window.localStorage.setItem('app_config', '{"onBoarding":false}');
|
||||||
window.localStorage.setItem('is-first-open', 'false');
|
window.localStorage.setItem('is-first-open', 'false');
|
||||||
@@ -56,8 +55,6 @@ test('stale first-open state still restores one local workspace', async ({
|
|||||||
() => window.currentWorkspace?.meta
|
() => window.currentWorkspace?.meta
|
||||||
);
|
);
|
||||||
expect(reloadedWorkspace?.id).toBe(firstWorkspace?.id);
|
expect(reloadedWorkspace?.id).toBe(firstWorkspace?.id);
|
||||||
|
|
||||||
await context.close();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('workspace selector does not offer workspace creation', async ({
|
test('workspace selector does not offer workspace creation', async ({
|
||||||
|
|||||||
Reference in New Issue
Block a user