mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-19 11:02:11 +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}
|
||||
# path to source code root
|
||||
SRC_ROOT=${2}
|
||||
# Keep Cargo artifacts in a stable location that the rest of this script can reference.
|
||||
export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$SRC_ROOT/../../../target}"
|
||||
# Keep Cargo artifacts in a stable repo-local location so Xcode does not inherit
|
||||
# a sandbox-specific CARGO_TARGET_DIR from the parent shell.
|
||||
export CARGO_TARGET_DIR="$SRC_ROOT/../../../target"
|
||||
# buildvariant from our xcconfigs
|
||||
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 { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import { DeletedAccountDialog } from './deleted-account';
|
||||
import { CollectionSelectorDialog } from './selectors/collection-selector';
|
||||
import { DateSelectorDialog } from './selectors/date-selector';
|
||||
import { DocSelectorDialog } from './selectors/doc-selector';
|
||||
@@ -16,6 +17,7 @@ import { SignInDialog } from './sign-in';
|
||||
|
||||
const GLOBAL_DIALOGS = {
|
||||
'sign-in': SignInDialog,
|
||||
'deleted-account': DeletedAccountDialog,
|
||||
} satisfies {
|
||||
[key in keyof GLOBAL_DIALOG_SCHEMA]?: React.FC<
|
||||
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,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { SettingGroup } from '../group';
|
||||
@@ -15,10 +15,16 @@ const loadFailedToastId = 'mobile-settings-devices-load-failed';
|
||||
export const DevicesGroup = () => {
|
||||
const t = useI18n();
|
||||
const auth = useService(AuthService);
|
||||
const loginStatus = useLiveData(auth.session.status$);
|
||||
const [sessions, setSessions] = useState<DeviceAuthSession[]>([]);
|
||||
const dismissTimer = useRef<number | undefined>(undefined);
|
||||
|
||||
const reload = useCallback(() => {
|
||||
if (loginStatus !== 'authenticated') {
|
||||
setSessions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
void auth
|
||||
.listDeviceSessions()
|
||||
.then(setSessions)
|
||||
@@ -36,7 +42,7 @@ export const DevicesGroup = () => {
|
||||
5000
|
||||
);
|
||||
});
|
||||
}, [auth, t]);
|
||||
}, [auth, loginStatus, t]);
|
||||
|
||||
useEffect(reload, [reload]);
|
||||
useEffect(
|
||||
@@ -71,6 +77,10 @@ export const DevicesGroup = () => {
|
||||
[auth, reload, t]
|
||||
);
|
||||
|
||||
if (loginStatus !== 'authenticated' || sessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingGroup title={t['com.affine.settings.devices.title']()}>
|
||||
{sessions.map(session => (
|
||||
|
||||
@@ -16,22 +16,17 @@ import * as styles from './styles.css';
|
||||
|
||||
export const ExperimentalFeatureSetting = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const t = useI18n();
|
||||
const title = t['com.affine.mobile.setting.experimental.features']();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingGroup title="Experimental">
|
||||
<RowLayout
|
||||
label={'Experimental Features'}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<SettingGroup title={t['com.affine.mobile.setting.experimental.title']()}>
|
||||
<RowLayout label={title} onClick={() => setOpen(true)}>
|
||||
<ArrowRightSmallIcon fontSize={22} />
|
||||
</RowLayout>
|
||||
</SettingGroup>
|
||||
<SwipeDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title="Experimental Features"
|
||||
>
|
||||
<SwipeDialog open={open} onOpenChange={setOpen} title={title}>
|
||||
<ExperimentalFeatureList />
|
||||
</SwipeDialog>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const group = style({
|
||||
@@ -6,3 +7,16 @@ export const group = style({
|
||||
gap: 4,
|
||||
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
|
||||
{...attrs}
|
||||
ref={ref}
|
||||
title={title}
|
||||
title={
|
||||
title ? <div className={styles.groupTitle}>{title}</div> : undefined
|
||||
}
|
||||
className={clsx(styles.group, className)}
|
||||
contentClassName={contentClassName}
|
||||
contentClassName={clsx(styles.groupContent, contentClassName)}
|
||||
contentStyle={contentStyle}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,38 +1,159 @@
|
||||
import { notify } from '@affine/component';
|
||||
import { AuthService } from '@affine/core/modules/cloud';
|
||||
import type {
|
||||
DialogComponentProps,
|
||||
WORKSPACE_DIALOG_SCHEMA,
|
||||
} 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 { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useEffect } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { AboutGroup } from './about';
|
||||
import { AppearanceGroup } from './appearance';
|
||||
import teamPeople from './assets/team-people.png';
|
||||
import { DevicesGroup } from './devices';
|
||||
import { ExperimentalFeatureSetting } from './experimental';
|
||||
import { SettingGroup } from './group';
|
||||
import { OthersGroup } from './others';
|
||||
import { DeleteAccount } from './others/delete-account';
|
||||
import { RowLayout } from './row.layout';
|
||||
import * as styles from './style.css';
|
||||
import { UserSubscription } from './subscription';
|
||||
import { SwipeDialog } from './swipe-dialog';
|
||||
import { UserProfile } from './user-profile';
|
||||
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 status = useLiveData(session.status$);
|
||||
useEffect(() => session.revalidate(), [session]);
|
||||
|
||||
useEffect(() => {
|
||||
session.revalidate();
|
||||
}, [session]);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<UserProfile />
|
||||
<UserSubscription />
|
||||
<UserProfile />
|
||||
<UserUsage />
|
||||
{status === 'authenticated' ? <DevicesGroup /> : null}
|
||||
<AppearanceGroup />
|
||||
<AboutGroup />
|
||||
<ExperimentalFeatureSetting />
|
||||
<TeamPromotionCard />
|
||||
<SupportGroup />
|
||||
<OthersGroup />
|
||||
<DangerZoneGroup onDeleteFinished={onDeleteFinished} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -48,18 +169,7 @@ export const SettingDialog = ({
|
||||
open
|
||||
onOpenChange={() => close()}
|
||||
>
|
||||
<MobileSetting />
|
||||
<MobileSetting onDeleteFinished={close} />
|
||||
</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 { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const description = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
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 { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { AuthService } from '@affine/core/modules/cloud';
|
||||
import { ConfirmModal, Input, notify } from '@affine/component';
|
||||
import { AuthService, ServerService } from '@affine/core/modules/cloud';
|
||||
import { WorkspacesService } from '@affine/core/modules/workspace';
|
||||
import { UserFriendlyError } from '@affine/error';
|
||||
import { Trans, useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { track } from '@affine/track';
|
||||
import { LiveData, useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { combineLatest, map, of, switchMap } from 'rxjs';
|
||||
|
||||
import { RowLayout } from '../row.layout';
|
||||
import * as styles from './delete-account.css';
|
||||
|
||||
export const DeleteAccount = () => {
|
||||
export const DeleteAccount = ({
|
||||
onDeleteFinished,
|
||||
}: {
|
||||
onDeleteFinished?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
const authService = useService(AuthService);
|
||||
const session = authService.session;
|
||||
const account = useLiveData(session.account$);
|
||||
const workspaceProfiles = workspacesService.getAllWorkspaceProfile();
|
||||
const isTeamWorkspaceOwner = workspaceProfiles.some(
|
||||
profile => profile.profile$.value?.isTeam && profile.profile$.value.isOwner
|
||||
);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const workspacesService = useService(WorkspacesService);
|
||||
const account = useLiveData(authService.session.account$);
|
||||
const isTeamWorkspaceOwner$ = useMemo(
|
||||
() =>
|
||||
LiveData.from<boolean | null>(
|
||||
workspacesService.list.workspaces$.pipe(
|
||||
switchMap(workspaces => {
|
||||
if (!workspaces.length) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setShowModal(true);
|
||||
return combineLatest(
|
||||
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 (
|
||||
<>
|
||||
{account ? (
|
||||
<RowLayout
|
||||
label={t['com.affine.mobile.setting.others.delete-account']()}
|
||||
onClick={openModal}
|
||||
>
|
||||
<ArrowRightSmallIcon fontSize={22} />
|
||||
</RowLayout>
|
||||
) : null}
|
||||
<RowLayout
|
||||
label={
|
||||
<span className={styles.deleteAccountLabel}>
|
||||
{t['com.affine.mobile.setting.others.delete-account']()}
|
||||
</span>
|
||||
}
|
||||
onClick={handleOpen}
|
||||
/>
|
||||
{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;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const onConfirm = useCallback(() => {
|
||||
const handleConfirm = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
@@ -71,7 +117,7 @@ const TeamOwnerWarningModal = ({
|
||||
confirmButtonOptions={{
|
||||
variant: 'primary',
|
||||
}}
|
||||
onConfirm={onConfirm}
|
||||
onConfirm={handleConfirm}
|
||||
cancelButtonOptions={{
|
||||
style: {
|
||||
display: 'none',
|
||||
@@ -84,50 +130,34 @@ const TeamOwnerWarningModal = ({
|
||||
const DeleteAccountModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onDeleteFinished,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onDeleteFinished?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const authService = useService(AuthService);
|
||||
const session = authService.session;
|
||||
const account = useLiveData(session.account$);
|
||||
const serverService = useService(ServerService);
|
||||
const account = useLiveData(authService.session.account$);
|
||||
const [phase, setPhase] = useState<'warning' | 'confirm'>('warning');
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
const navigate = useNavigate();
|
||||
const onConfirm = useCallback(() => {
|
||||
navigate('/');
|
||||
}, [navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setPhase('warning');
|
||||
setEmail('');
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const handleDeleteAccount = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await authService.deleteAccount();
|
||||
track.$.$.auth.deleteAccount();
|
||||
openConfirmModal({
|
||||
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',
|
||||
},
|
||||
});
|
||||
onDeleteFinished?.();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
const error = UserFriendlyError.fromAny(err);
|
||||
@@ -135,46 +165,113 @@ const DeleteAccountModal = ({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [authService, onConfirm, openConfirmModal, t]);
|
||||
}, [authService, onDeleteFinished]);
|
||||
|
||||
const onDeleteAccountConfirm = useAsyncCallback(async () => {
|
||||
await handleDeleteAccount();
|
||||
const handleDeleteAccountClick = useCallback(() => {
|
||||
handleDeleteAccount().catch(console.error);
|
||||
}, [handleDeleteAccount]);
|
||||
|
||||
const onCancel = useCallback(() => {
|
||||
onOpenChange(false);
|
||||
}, [onOpenChange]);
|
||||
|
||||
if (!account) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={t['com.affine.setting.account.delete.confirm-title']()}
|
||||
description={
|
||||
<Trans
|
||||
i18nKey="com.affine.setting.account.delete.confirm-description-2"
|
||||
components={{
|
||||
1: <strong />,
|
||||
}}
|
||||
<>
|
||||
<ConfirmModal
|
||||
open={open && phase === 'warning'}
|
||||
onOpenChange={nextOpen => {
|
||||
if (!nextOpen) {
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
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
|
||||
/>
|
||||
</ConfirmModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 { RowLayout } from '../row.layout';
|
||||
import { DeleteAccount } from './delete-account';
|
||||
import { hotTag } from './index.css';
|
||||
|
||||
export const OthersGroup = () => {
|
||||
const t = useI18n();
|
||||
@@ -11,12 +9,7 @@ export const OthersGroup = () => {
|
||||
return (
|
||||
<SettingGroup title={t['com.affine.mobile.setting.others.title']()}>
|
||||
<RowLayout
|
||||
label={
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
{t['com.affine.mobile.setting.others.discord']()}
|
||||
<div className={hotTag}>Hot</div>
|
||||
</div>
|
||||
}
|
||||
label={t['com.affine.mobile.setting.others.discord']()}
|
||||
href="https://discord.com/invite/whd5mjYqVw"
|
||||
/>
|
||||
<RowLayout
|
||||
@@ -38,7 +31,6 @@ export const OthersGroup = () => {
|
||||
label={t['com.affine.mobile.setting.others.terms']()}
|
||||
href="https://affine.pro/terms"
|
||||
/>
|
||||
<DeleteAccount />
|
||||
</SettingGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { ConfigModal } from '@affine/core/components/mobile';
|
||||
import { DualLinkIcon } from '@blocksuite/icons/rc';
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import type { KeyboardEvent, PropsWithChildren, ReactNode } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import * as styles from './style.css';
|
||||
|
||||
@@ -14,25 +16,65 @@ export const RowLayout = ({
|
||||
href?: string;
|
||||
onClick?: () => void;
|
||||
}>) => {
|
||||
const content = (
|
||||
<ConfigModal.Row
|
||||
data-testid="setting-row"
|
||||
className={styles.baseSettingItem}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.baseSettingItemName}>{label}</div>
|
||||
<div className={styles.baseSettingItemAction}>
|
||||
{children ||
|
||||
(href ? <DualLinkIcon className={styles.linkIcon} /> : null)}
|
||||
</div>
|
||||
</ConfigModal.Row>
|
||||
const isLinkRow = !!href && !onClick;
|
||||
const isButtonRow = !!onClick;
|
||||
const isInteractive = isLinkRow || isButtonRow;
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
onClick?.();
|
||||
}, [onClick]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (!isButtonRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== 'Enter' && event.key !== ' ') {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
handleTrigger();
|
||||
},
|
||||
[handleTrigger, isButtonRow]
|
||||
);
|
||||
|
||||
return href ? (
|
||||
<a target="_blank" href={href} rel="noreferrer">
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
content
|
||||
const content = (
|
||||
<>
|
||||
<div className={styles.baseSettingItemName}>{label}</div>
|
||||
<div className={styles.baseSettingItemAction}>
|
||||
{children ??
|
||||
(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 { style } from '@vanilla-extract/css';
|
||||
|
||||
export const pageTitle = style([bodyEmphasized]);
|
||||
export const pageTitle = style([
|
||||
bodyEmphasized,
|
||||
{
|
||||
fontSize: 19,
|
||||
lineHeight: '24px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 16,
|
||||
padding: '24px 16px',
|
||||
gap: 22,
|
||||
paddingTop: 0,
|
||||
paddingRight: 16,
|
||||
paddingBottom: 'calc(env(safe-area-inset-bottom) + 20px)',
|
||||
paddingLeft: 16,
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const baseSettingItem = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: 32,
|
||||
padding: 8,
|
||||
gap: 12,
|
||||
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([
|
||||
bodyRegular,
|
||||
{
|
||||
color: cssVarV2('text/primary'),
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const baseSettingItemAction = style([
|
||||
baseSettingItemName,
|
||||
bodyRegular,
|
||||
{
|
||||
color: cssVarV2('text/placeholder'),
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
marginLeft: 12,
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: 6,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const linkIcon = style({
|
||||
fontSize: 24,
|
||||
color: cssVarV2('icon/primary'),
|
||||
export const linkRowContent = style({
|
||||
display: 'flex',
|
||||
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 { AuthService, ServerService } from '@affine/core/modules/cloud';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import { NativePaywallService } from '@affine/core/modules/paywall';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import proDiamond from '../assets/pro-diamond.png';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const UserSubscription = () => {
|
||||
const serverService = useService(ServerService);
|
||||
const authService = useService(AuthService);
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const nativePaywallProvider =
|
||||
useService(NativePaywallService).getNativePaywallProvider();
|
||||
const t = useI18n();
|
||||
@@ -16,40 +20,40 @@ export const UserSubscription = () => {
|
||||
const supported = useLiveData(
|
||||
serverService.server.features$.map(f => f.payment)
|
||||
);
|
||||
|
||||
const loggedIn = useLiveData(authService.session.status$) === 'authenticated';
|
||||
|
||||
if (!loggedIn) {
|
||||
return null;
|
||||
}
|
||||
const handleOpen = useCallback(() => {
|
||||
if (!loggedIn) {
|
||||
globalDialogService.open('sign-in', {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supported) {
|
||||
// TODO: enable this
|
||||
// return null;
|
||||
}
|
||||
void nativePaywallProvider?.showPaywall('Pro').catch(console.error);
|
||||
}, [globalDialogService, loggedIn, nativePaywallProvider]);
|
||||
|
||||
if (!nativePaywallProvider) {
|
||||
if (!nativePaywallProvider || supported === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.title}>
|
||||
{t['com.affine.payment.subscription.title']()}
|
||||
</div>
|
||||
<div className={styles.description}>
|
||||
{t['com.affine.payment.subscription.description']()}
|
||||
<div className={styles.headerRow}>
|
||||
<div className={styles.perkIconWrapper}>
|
||||
<img className={styles.perkIcon} src={proDiamond} alt="" />
|
||||
</div>
|
||||
<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>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void nativePaywallProvider.showPaywall('Pro').catch(console.error)
|
||||
}
|
||||
>
|
||||
{t['com.affine.payment.subscription.button']()}
|
||||
<Button className={styles.button} variant="primary" onClick={handleOpen}>
|
||||
{t['com.affine.mobile.setting.subscription.button']()}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,35 +3,74 @@ import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
border: `1px solid ${cssVarV2('database/border')}`,
|
||||
borderRadius: '12px',
|
||||
padding: '10px 16px',
|
||||
backgroundColor: cssVarV2('edgeless/selection/selectionMarqueeBackground'),
|
||||
flexDirection: 'column',
|
||||
gap: 18,
|
||||
borderRadius: 24,
|
||||
padding: '24px',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
boxSizing: 'border-box',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
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({
|
||||
fontSize: '17px',
|
||||
fontSize: '18px',
|
||||
lineHeight: '22px',
|
||||
fontWeight: 600,
|
||||
color: cssVarV2('text/primary'),
|
||||
textAlign: 'left',
|
||||
});
|
||||
|
||||
export const perkIcon = style({
|
||||
width: 18,
|
||||
height: 18,
|
||||
flexShrink: 0,
|
||||
objectFit: 'contain',
|
||||
});
|
||||
|
||||
export const description = style({
|
||||
fontSize: '13px',
|
||||
lineHeight: '18px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '19px',
|
||||
fontWeight: 400,
|
||||
color: cssVarV2('text/secondary'),
|
||||
maxWidth: 250,
|
||||
});
|
||||
|
||||
export const button = style({
|
||||
width: '100%',
|
||||
minHeight: 48,
|
||||
fontSize: '15px',
|
||||
fontWeight: 600,
|
||||
borderRadius: 999,
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { type ReactNode } from 'react';
|
||||
|
||||
import { UserPlanTag } from '../../../components';
|
||||
import { SettingGroup } from '../group';
|
||||
import { RowLayout } from '../row.layout';
|
||||
import * as styles from './style.css';
|
||||
|
||||
export const UserProfile = () => {
|
||||
@@ -26,15 +27,17 @@ const BaseLayout = ({
|
||||
avatar,
|
||||
title,
|
||||
caption,
|
||||
sectionTitle,
|
||||
onClick,
|
||||
}: {
|
||||
avatar: ReactNode;
|
||||
title: ReactNode;
|
||||
caption: ReactNode;
|
||||
sectionTitle: string;
|
||||
onClick?: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<SettingGroup contentStyle={{ padding: '10px 8px 10px 10px' }}>
|
||||
<SettingGroup title={sectionTitle} contentStyle={{ padding: '12px 14px' }}>
|
||||
<div className={styles.profile} onClick={onClick}>
|
||||
<div className={styles.avatarWrapper}>{avatar}</div>
|
||||
<div className={styles.content}>
|
||||
@@ -51,9 +54,11 @@ const AuthorizedUserProfile = () => {
|
||||
const session = useService(AuthService).session;
|
||||
const account = useLiveData(session.account$);
|
||||
const confirmSignOut = useSignOut();
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
sectionTitle={t['com.affine.mobile.setting.account.title']()}
|
||||
avatar={
|
||||
<Avatar
|
||||
size={48}
|
||||
@@ -75,15 +80,15 @@ const AuthorizedUserProfile = () => {
|
||||
};
|
||||
|
||||
const UnauthorizedUserProfile = () => {
|
||||
const { t } = useI18n();
|
||||
const t = useI18n();
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
onClick={() => globalDialogService.open('sign-in', {})}
|
||||
avatar={<Avatar size={48} rounded={4} />}
|
||||
title={t(`com.affine.settings.sign`)}
|
||||
caption={t(`com.affine.setting.sign.message`)}
|
||||
/>
|
||||
<SettingGroup title={t['com.affine.mobile.setting.account.title']()}>
|
||||
<RowLayout
|
||||
label={t['com.affine.mobile.setting.account.sign-in']()}
|
||||
onClick={() => globalDialogService.open('sign-in', {})}
|
||||
/>
|
||||
</SettingGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -33,16 +33,27 @@ const ellipsis = style({
|
||||
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([
|
||||
subHeadlineRegular,
|
||||
{ color: cssVarV2('text/secondary') },
|
||||
{
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: 16,
|
||||
lineHeight: '20px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const suffixIcon = style({
|
||||
fontSize: 30,
|
||||
color: cssVarV2('icon/primary'),
|
||||
fontSize: 20,
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
|
||||
export const emailInfo = style([ellipsis, { width: '100%' }]);
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
{
|
||||
"ar": 89,
|
||||
"ca": 86,
|
||||
"ar": 88,
|
||||
"ca": 85,
|
||||
"da": 3,
|
||||
"de": 96,
|
||||
"el-GR": 85,
|
||||
"el-GR": 84,
|
||||
"en": 100,
|
||||
"es-AR": 85,
|
||||
"es-CL": 86,
|
||||
"es": 85,
|
||||
"fa": 85,
|
||||
"fr": 89,
|
||||
"es-AR": 84,
|
||||
"es-CL": 85,
|
||||
"es": 84,
|
||||
"fa": 84,
|
||||
"fr": 88,
|
||||
"hi": 1,
|
||||
"it": 86,
|
||||
"ja": 85,
|
||||
"kk": 92,
|
||||
"ko": 86,
|
||||
"ja": 84,
|
||||
"kk": 91,
|
||||
"ko": 85,
|
||||
"nb-NO": 42,
|
||||
"pl": 86,
|
||||
"pt-BR": 85,
|
||||
"ru": 87,
|
||||
"pt-BR": 84,
|
||||
"ru": 86,
|
||||
"sv-SE": 85,
|
||||
"tr": 92,
|
||||
"uk": 85,
|
||||
"ur": 92,
|
||||
"zh-Hans": 96,
|
||||
"tr": 91,
|
||||
"uk": 84,
|
||||
"ur": 91,
|
||||
"zh-Hans": 95,
|
||||
"zh-Hant": 87
|
||||
}
|
||||
|
||||
@@ -3035,6 +3035,14 @@ export function useAFFiNEI18N(): {
|
||||
* `About`
|
||||
*/
|
||||
["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`
|
||||
*/
|
||||
@@ -3051,6 +3059,14 @@ export function useAFFiNEI18N(): {
|
||||
* `Appearance`
|
||||
*/
|
||||
["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`
|
||||
*/
|
||||
@@ -3060,7 +3076,7 @@ export function useAFFiNEI18N(): {
|
||||
*/
|
||||
["com.affine.mobile.setting.others.github"](): string;
|
||||
/**
|
||||
* `Discord Group`
|
||||
* `Discord group`
|
||||
*/
|
||||
["com.affine.mobile.setting.others.discord"](): string;
|
||||
/**
|
||||
@@ -3080,9 +3096,49 @@ export function useAFFiNEI18N(): {
|
||||
*/
|
||||
["com.affine.mobile.setting.others.website"](): string;
|
||||
/**
|
||||
* `Delete my account`
|
||||
* `Delete Account`
|
||||
*/
|
||||
["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?`
|
||||
*/
|
||||
@@ -5242,6 +5298,10 @@ export function useAFFiNEI18N(): {
|
||||
* `Delete your account?`
|
||||
*/
|
||||
["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`
|
||||
*/
|
||||
@@ -10759,6 +10819,14 @@ export const TypedTrans: {
|
||||
["com.affine.setting.account.delete.confirm-delete-description-2"]: ComponentType<TypedTransProps<Readonly<{}>, {
|
||||
["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>.`
|
||||
*/
|
||||
|
||||
@@ -750,18 +750,32 @@
|
||||
"com.affine.mobile.setting.about.appVersion": "App version",
|
||||
"com.affine.mobile.setting.about.editorVersion": "Editor version",
|
||||
"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.language": "Display language",
|
||||
"com.affine.mobile.setting.appearance.theme": "Color mode",
|
||||
"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.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.terms": "Terms of use",
|
||||
"com.affine.mobile.setting.others.title": "Privacy & others",
|
||||
"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.link": "Start AFFiNE without an account",
|
||||
"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-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.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.confirm-button": "Delete",
|
||||
"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 ({
|
||||
browser,
|
||||
context,
|
||||
}) => {
|
||||
const context = await browser.newContext();
|
||||
await context.addInitScript(() => {
|
||||
window.localStorage.setItem('app_config', '{"onBoarding":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
|
||||
);
|
||||
expect(reloadedWorkspace?.id).toBe(firstWorkspace?.id);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('workspace selector does not offer workspace creation', async ({
|
||||
|
||||
Reference in New Issue
Block a user