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 0000000000..6bc9fb3617 Binary files /dev/null and b/packages/frontend/core/src/mobile/dialogs/setting/assets/pro-diamond.png differ diff --git a/packages/frontend/core/src/mobile/dialogs/setting/assets/team-people.png b/packages/frontend/core/src/mobile/dialogs/setting/assets/team-people.png new file mode 100644 index 0000000000..6674e04618 Binary files /dev/null and b/packages/frontend/core/src/mobile/dialogs/setting/assets/team-people.png differ diff --git a/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx b/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx index 267e4f5eb1..6dc36f5bd9 100644 --- a/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx +++ b/packages/frontend/core/src/mobile/dialogs/setting/devices/index.tsx @@ -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([]); 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 ({