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:
keepClamDown
2026-08-19 10:30:37 +08:00
committed by GitHub
parent b4c8548c09
commit d6fd3174b2
25 changed files with 1260 additions and 297 deletions
@@ -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
} }
+70 -2
View File
@@ -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>.`
*/ */
+18 -2
View File
@@ -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",
+1 -4
View File
@@ -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 ({