From d6fd3174b22939072cd01450a2fefb0191e76b8f Mon Sep 17 00:00:00 2001 From: keepClamDown <59951516+keepClamDown@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:30:37 +0800 Subject: [PATCH] feat(ios): refresh mobile settings layout (#15234) ## 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. --------- Co-authored-by: DarkSky --- .../apps/ios/App/xc-universal-binary.sh | 5 +- .../src/mobile/dialogs/deleted-account.tsx | 54 +++ .../core/src/mobile/dialogs/index.tsx | 2 + .../dialogs/setting/assets/pro-diamond.png | Bin 0 -> 7238 bytes .../dialogs/setting/assets/team-people.png | Bin 0 -> 6503 bytes .../mobile/dialogs/setting/devices/index.tsx | 14 +- .../dialogs/setting/experimental/index.tsx | 15 +- .../src/mobile/dialogs/setting/group.css.ts | 14 + .../core/src/mobile/dialogs/setting/group.tsx | 6 +- .../core/src/mobile/dialogs/setting/index.tsx | 142 +++++- .../setting/others/delete-account.css.ts | 9 + .../setting/others/delete-account.spec.tsx | 408 ++++++++++++++++++ .../dialogs/setting/others/delete-account.tsx | 287 ++++++++---- .../dialogs/setting/others/index.css.ts | 57 --- .../mobile/dialogs/setting/others/index.tsx | 10 +- .../src/mobile/dialogs/setting/row.layout.tsx | 82 +++- .../src/mobile/dialogs/setting/style.css.ts | 172 +++++++- .../dialogs/setting/subscription/index.tsx | 48 ++- .../setting/subscription/styles.css.ts | 61 ++- .../dialogs/setting/user-profile/index.tsx | 21 +- .../dialogs/setting/user-profile/style.css.ts | 19 +- .../i18n/src/i18n-completenesses.json | 34 +- packages/frontend/i18n/src/i18n.gen.ts | 72 +++- packages/frontend/i18n/src/resources/en.json | 20 +- tests/affine-mobile/e2e/home.spec.ts | 5 +- 25 files changed, 1260 insertions(+), 297 deletions(-) create mode 100644 packages/frontend/core/src/mobile/dialogs/deleted-account.tsx create mode 100644 packages/frontend/core/src/mobile/dialogs/setting/assets/pro-diamond.png create mode 100644 packages/frontend/core/src/mobile/dialogs/setting/assets/team-people.png create mode 100644 packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.spec.tsx delete mode 100644 packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts diff --git a/packages/frontend/apps/ios/App/xc-universal-binary.sh b/packages/frontend/apps/ios/App/xc-universal-binary.sh index 54fa1bf132..5b4fb1036c 100644 --- a/packages/frontend/apps/ios/App/xc-universal-binary.sh +++ b/packages/frontend/apps/ios/App/xc-universal-binary.sh @@ -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:]') diff --git a/packages/frontend/core/src/mobile/dialogs/deleted-account.tsx b/packages/frontend/core/src/mobile/dialogs/deleted-account.tsx new file mode 100644 index 0000000000..25936d1c0f --- /dev/null +++ b/packages/frontend/core/src/mobile/dialogs/deleted-account.tsx @@ -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) => { + const t = useI18n(); + const { jumpToIndex } = useNavigateHelper(); + + const handleDone = useCallback(() => { + close(); + jumpToIndex(RouteLogic.REPLACE); + }, [close, jumpToIndex]); + + return ( + + + {t['com.affine.setting.account.delete.success-description-1']()} + +
+
+ + {t['com.affine.setting.account.delete.success-description-2']()} + + + } + confirmText={t['Done']()} + onOpenChange={handleDone} + onConfirm={handleDone} + confirmButtonOptions={{ + variant: 'primary', + }} + cancelButtonOptions={{ + style: { + display: 'none', + }, + }} + /> + ); +}; diff --git a/packages/frontend/core/src/mobile/dialogs/index.tsx b/packages/frontend/core/src/mobile/dialogs/index.tsx index 452ccf0f71..0b6291c909 100644 --- a/packages/frontend/core/src/mobile/dialogs/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/index.tsx @@ -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 diff --git a/packages/frontend/core/src/mobile/dialogs/setting/assets/pro-diamond.png b/packages/frontend/core/src/mobile/dialogs/setting/assets/pro-diamond.png new file mode 100644 index 0000000000000000000000000000000000000000..6bc9fb36170fbf6b525697aa382d70cbc3ec9e93 GIT binary patch literal 7238 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

6BbsJQ7P84?TkClq|s8p2u%h<{%`Yk6wjm| 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 { const t = useI18n(); const auth = useService(AuthService); + const loginStatus = useLiveData(auth.session.status$); const [sessions, setSessions] = useState([]); const dismissTimer = useRef(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 ( {sessions.map(session => ( diff --git a/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx index 2391c345ee..1d322fc887 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/experimental/index.tsx @@ -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 ( <> - - setOpen(true)} - > + + setOpen(true)}> - + diff --git a/packages/frontend/core/src/mobile/dialogs/setting/group.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/group.css.ts index e6e9377969..99af073ad7 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/group.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/group.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/group.tsx b/packages/frontend/core/src/mobile/dialogs/setting/group.tsx index 0a6682bafd..623c184512 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/group.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/group.tsx @@ -27,9 +27,11 @@ export const SettingGroup = forwardRef( {title} : undefined + } className={clsx(styles.group, className)} - contentClassName={contentClassName} + contentClassName={clsx(styles.groupContent, contentClassName)} contentStyle={contentStyle} > {children} diff --git a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx index e8868d9256..2bd0ac7df6 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/index.tsx @@ -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 ( + + {AFFINE_MOBILE_STORE_URL ? ( + urlService.openExternal(AFFINE_MOBILE_STORE_URL)} + /> + ) : null} + void shareApp()} + /> + + ); +}; + +const TeamPromotionCard = () => { + const t = useI18n(); + const urlService = useService(UrlService); + + return ( + + ); +}; + +const DangerZoneGroup = ({ + onDeleteFinished, +}: { + onDeleteFinished?: () => void; +}) => { + const t = useI18n(); + const authService = useService(AuthService); + const account = useLiveData(authService.session.account$); + + if (!account) { + return null; + } + + return ( + + {t['com.affine.mobile.setting.danger-zone.title']()} + + } + > + + + ); +}; + +const MobileSetting = ({ + onDeleteFinished, +}: { + onDeleteFinished?: () => void; +}) => { const session = useService(AuthService).session; const status = useLiveData(session.status$); - useEffect(() => session.revalidate(), [session]); + + useEffect(() => { + session.revalidate(); + }, [session]); return (

- + {status === 'authenticated' ? : null} + + +
); }; @@ -48,18 +169,7 @@ export const SettingDialog = ({ open onOpenChange={() => close()} > - + ); - - // return ( - // close()} - // onBack={close} - // > - // - // - // ); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.css.ts index 4db0972067..aad196b349 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.spec.tsx b/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.spec.tsx new file mode 100644 index 0000000000..bec62724cb --- /dev/null +++ b/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.spec.tsx @@ -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 = { + 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 | null, + profile$: null as SubjectLike | null, + profileLoading$: null as SubjectLike | 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 ( +
+
{title}
+
{description}
+ {children} + {onCancel !== false ? ( + + ) : null} + +
+ ); + }, + Input: ({ onChange, ...props }: any) => ( + { + 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 }) => {i18nKey}, + 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('@toeverything/infra'); + const { BehaviorSubject } = await import('rxjs'); + + const workspaces$ = new BehaviorSubject([]); + const profile$ = new BehaviorSubject(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; + }) => , +})); + +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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + expect( + screen.getByRole('button', { + name: 'com.affine.mobile.setting.others.delete-account', + }) + ).toBeTruthy(); + }); +}); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.tsx b/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.tsx index 63f1960e92..153335b638 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/others/delete-account.tsx @@ -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( + 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 ? ( - - - - ) : null} + + {t['com.affine.mobile.setting.others.delete-account']()} + + } + onClick={handleOpen} + /> {isTeamWorkspaceOwner ? ( - + ) : ( - + )} ); @@ -56,9 +101,10 @@ const TeamOwnerWarningModal = ({ onOpenChange: (open: boolean) => void; }) => { const t = useI18n(); - const onConfirm = useCallback(() => { + const handleConfirm = useCallback(() => { onOpenChange(false); }, [onOpenChange]); + return ( 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: ( - <> - - {t['com.affine.setting.account.delete.success-description-1']()} - -
- - {t['com.affine.setting.account.delete.success-description-2']()} - - - ), - 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 ( - , - }} + <> + { + if (!nextOpen) { + onOpenChange(false); + } + }} + title={t['com.affine.setting.account.delete.confirm-title']()} + description={ + <> + , + }} + values={{ + server: + serverService.server.id !== 'affine-cloud' + ? `${serverService.server.config$.value.serverName} (${serverService.server.baseUrl})` + : serverService.server.config$.value.serverName, + }} + /> +
+
+ , + }} + /> + + } + descriptionClassName={styles.description} + confirmText={t['Continue']()} + confirmButtonOptions={{ + variant: 'primary', + onClick: () => { + setPhase('confirm'); + }, + }} + cancelText={t['Cancel']()} + cancelButtonOptions={{ + variant: 'primary', + }} + rowFooter + /> + { + if (!nextOpen) { + onOpenChange(false); + } + }} + title={t['com.affine.setting.account.delete.email-confirm-title']()} + description={ + , + }} + 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 + > + - } - 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 - /> + + ); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts deleted file mode 100644 index a3c7bb33fa..0000000000 --- a/packages/frontend/core/src/mobile/dialogs/setting/others/index.css.ts +++ /dev/null @@ -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', - }, - }, - }, - }, -}); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/others/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/others/index.tsx index 7a94ad5dd9..141d59abb9 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/others/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/others/index.tsx @@ -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 ( - {t['com.affine.mobile.setting.others.discord']()} -
Hot
- - } + label={t['com.affine.mobile.setting.others.discord']()} href="https://discord.com/invite/whd5mjYqVw" /> { label={t['com.affine.mobile.setting.others.terms']()} href="https://affine.pro/terms" /> -
); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/row.layout.tsx b/packages/frontend/core/src/mobile/dialogs/setting/row.layout.tsx index 99543878a5..8a346fb84c 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/row.layout.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/row.layout.tsx @@ -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 = ( - -
{label}
-
- {children || - (href ? : null)} -
-
+ const isLinkRow = !!href && !onClick; + const isButtonRow = !!onClick; + const isInteractive = isLinkRow || isButtonRow; + + const handleTrigger = useCallback(() => { + onClick?.(); + }, [onClick]); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (!isButtonRow) { + return; + } + + if (event.key !== 'Enter' && event.key !== ' ') { + return; + } + + event.preventDefault(); + handleTrigger(); + }, + [handleTrigger, isButtonRow] ); - return href ? ( - - {content} - - ) : ( - content + const content = ( + <> +
{label}
+
+ {children ?? + (isInteractive ? ( + + ) : null)} +
+ + ); + + return ( + + {isLinkRow ? ( + + {content} + + ) : ( + content + )} + ); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts index cbf0a168b2..1ec03b1081 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/style.css.ts @@ -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'), }); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/subscription/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/subscription/index.tsx index e9b46b98a4..04a9044aee 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/subscription/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/subscription/index.tsx @@ -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 (
-
- {t['com.affine.payment.subscription.title']()} -
-
- {t['com.affine.payment.subscription.description']()} +
+
+ +
+
+
+ {t['com.affine.mobile.setting.subscription.title']()} +
+
+ {t['com.affine.mobile.setting.subscription.description']()} +
+
-
); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/subscription/styles.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/subscription/styles.css.ts index f18bab4f59..0b0138fc72 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/subscription/styles.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/subscription/styles.css.ts @@ -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, }); diff --git a/packages/frontend/core/src/mobile/dialogs/setting/user-profile/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/user-profile/index.tsx index ef5567b9e5..9d23f554ff 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/user-profile/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/user-profile/index.tsx @@ -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 ( - +
{avatar}
@@ -51,9 +54,11 @@ const AuthorizedUserProfile = () => { const session = useService(AuthService).session; const account = useLiveData(session.account$); const confirmSignOut = useSignOut(); + const t = useI18n(); return ( { }; const UnauthorizedUserProfile = () => { - const { t } = useI18n(); + const t = useI18n(); const globalDialogService = useService(GlobalDialogService); return ( - globalDialogService.open('sign-in', {})} - avatar={} - title={t(`com.affine.settings.sign`)} - caption={t(`com.affine.setting.sign.message`)} - /> + + globalDialogService.open('sign-in', {})} + /> + ); }; diff --git a/packages/frontend/core/src/mobile/dialogs/setting/user-profile/style.css.ts b/packages/frontend/core/src/mobile/dialogs/setting/user-profile/style.css.ts index ea7e71315a..816b9d67ba 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/user-profile/style.css.ts +++ b/packages/frontend/core/src/mobile/dialogs/setting/user-profile/style.css.ts @@ -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%' }]); diff --git a/packages/frontend/i18n/src/i18n-completenesses.json b/packages/frontend/i18n/src/i18n-completenesses.json index 58c40a9871..d7e71e67c5 100644 --- a/packages/frontend/i18n/src/i18n-completenesses.json +++ b/packages/frontend/i18n/src/i18n-completenesses.json @@ -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 } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 7dcdf88864..5e4efc0ef0 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -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, { ["1"]: JSX.Element; }>>; + /** + * `Type <1>{{email}} to confirm account deletion.` + */ + ["com.affine.setting.account.delete.email-confirm-description"]: ComponentType>; /** * `Don't have the app? <1>Click to download.` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 838842292f..8180215f01 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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}}?", "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.", + "com.affine.setting.account.delete.email-confirm-title": "Confirm your email", + "com.affine.setting.account.delete.email-confirm-description": "Type <1>{{email}} 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", diff --git a/tests/affine-mobile/e2e/home.spec.ts b/tests/affine-mobile/e2e/home.spec.ts index 47609c3db7..f3b84906be 100644 --- a/tests/affine-mobile/e2e/home.spec.ts +++ b/tests/affine-mobile/e2e/home.spec.ts @@ -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 ({