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}
# 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
}
+70 -2
View File
@@ -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>.`
*/
+18 -2
View File
@@ -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",
+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 ({
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 ({