feat(ios): polish onboarding and native sign-in flow (#15217)

## Summary
- Reworks the iOS onboarding, native sign-in, and paywall flow so
first-install and cold-start entry stay native, adaptive, and aligned
with the current login/subscription gates.
- Removes onboarding swipe paging, refines onboarding/paywall layout
behavior, and keeps AI/paywall entry behavior consistent for logged-in
and subscribed users.
- Adds the new mobile all-docs empty states with localized copy and
dialog entry points, and closes the remaining review follow-ups by
removing the onboarding plan artifact and dropping the iOS AI
subscription bypass.

## Test plan
- Built the iOS app for simulator with `xcodebuild -workspace
App.xcworkspace -scheme App -configuration Debug -sdk iphonesimulator
-destination 'generic/platform=iOS Simulator' ARCHS=arm64
ONLY_ACTIVE_ARCH=YES CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO
build` during the native onboarding/sign-in flow work.
- Let the repo pre-commit hooks run on the latest follow-up commit
(`prettier` + `eslint --fix`).
- Checked diagnostics for the edited TS/TSX files after the review
follow-up changes.
- Manually iterated on onboarding/native sign-in/paywall UI states in
simulator during implementation.

---------

Co-authored-by: DarkSky <darksky2048@gmail.com>
This commit is contained in:
keepClamDown
2026-08-21 08:12:27 +08:00
committed by GitHub
parent c57004ea2c
commit 73e2d10973
130 changed files with 6478 additions and 2268 deletions
@@ -1,5 +1,6 @@
import { generateSubscriptionCallbackLink } from '@affine/core/components/hooks/affine/use-subscription-notify';
import { AuthService, SubscriptionService } from '@affine/core/modules/cloud';
import { NativePaywallService } from '@affine/core/modules/paywall';
import { UrlService } from '@affine/core/modules/url';
import { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
import { useFramework } from '@toeverything/infra';
@@ -24,6 +25,14 @@ export const useAISubscribe = () => {
return;
}
const nativePaywallProvider = framework
.get(NativePaywallService)
.getNativePaywallProvider();
if (nativePaywallProvider) {
await nativePaywallProvider.showPaywall('AI');
return;
}
const idempotencyKey = nanoid();
const checkoutOptions = {
recurring: SubscriptionRecurring.Yearly,
@@ -30,6 +30,7 @@ const CollectionDesc = () => {
export const CollectionRenameDialog = ({
title,
confirmText,
descRenderer,
...props
}: RenameDialogProps) => {
return (
@@ -37,7 +38,7 @@ export const CollectionRenameDialog = ({
title={title}
confirmText={confirmText}
{...props}
descRenderer={CollectionDesc}
descRenderer={descRenderer ?? CollectionDesc}
/>
);
};
@@ -1,4 +1,3 @@
import { usePromptModal } from '@affine/component';
import { NavigationPanelTreeRoot } from '@affine/core/desktop/components/navigation-panel';
import { CollectionService } from '@affine/core/modules/collection';
import { NavigationPanelService } from '@affine/core/modules/navigation-panel';
@@ -7,11 +6,12 @@ import { useI18n } from '@affine/i18n';
import { track } from '@affine/track';
import { AddCollectionIcon } from '@blocksuite/icons/rc';
import { useLiveData, useServices } from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { AddItemPlaceholder } from '../../layouts/add-item-placeholder';
import { CollapsibleSection } from '../../layouts/collapsible-section';
import { NavigationPanelCollectionNode } from '../../nodes/collection';
import { CollectionRenameDialog } from '../../nodes/collection/dialog';
import * as styles from './index.css';
export const NavigationPanelCollections = () => {
@@ -24,42 +24,24 @@ export const NavigationPanelCollections = () => {
});
const path = useMemo(() => ['collections'], []);
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const { openPromptModal } = usePromptModal();
const [showNewCollectionDialog, setShowNewCollectionDialog] = useState(false);
const handleCreateCollection = useCallback(() => {
openPromptModal({
title: t['com.affine.editCollection.saveCollection'](),
label: t['com.affine.editCollectionName.name'](),
inputOptions: {
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
},
children: (
<div className={styles.createTips}>
{t['com.affine.editCollectionName.createTips']()}
</div>
),
confirmText: t['com.affine.editCollection.save'](),
cancelText: t['com.affine.editCollection.button.cancel'](),
confirmButtonOptions: {
variant: 'primary',
},
onConfirm(name) {
const id = collectionService.createCollection({ name });
track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection',
});
workbenchService.workbench.openCollection(id);
navigationPanelService.setCollapsed(path, false);
},
});
}, [
collectionService,
navigationPanelService,
path,
openPromptModal,
t,
workbenchService.workbench,
]);
const handleCreateCollection = useCallback(
(name: string) => {
const id = collectionService.createCollection({ name });
track.$.navigationPanel.organize.createOrganizeItem({
type: 'collection',
});
workbenchService.workbench.openCollection(id);
navigationPanelService.setCollapsed(path, false);
},
[
collectionService,
navigationPanelService,
path,
workbenchService.workbench,
]
);
return (
<CollapsibleSection
@@ -79,7 +61,22 @@ export const NavigationPanelCollections = () => {
icon={<AddCollectionIcon />}
data-testid="navigation-panel-bar-add-collection-button"
label={t['com.affine.rootAppSidebar.collection.new']()}
onClick={() => handleCreateCollection()}
onClick={() => setShowNewCollectionDialog(true)}
/>
<CollectionRenameDialog
open={showNewCollectionDialog}
onOpenChange={setShowNewCollectionDialog}
onConfirm={handleCreateCollection}
title={t['com.affine.m.explorer.collection.new-dialog-title']()}
confirmText={t['com.affine.editCollection.save']()}
inputProps={{
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
}}
descRenderer={() => (
<div className={styles.createTips}>
{t['com.affine.editCollectionName.createTips']()}
</div>
)}
/>
</NavigationPanelTreeRoot>
</CollapsibleSection>
@@ -8,10 +8,12 @@ export const MobileSignInPanel = ({
onClose,
server,
initStep,
showCloseButton = false,
}: {
onClose: () => void;
server?: string;
initStep?: SignInStep;
showCloseButton?: boolean;
}) => {
const onAuthenticated = useCallback(
(status: AuthSessionStatus) => {
@@ -23,7 +25,7 @@ export const MobileSignInPanel = ({
);
return (
<MobileSignInLayout>
<MobileSignInLayout showCloseButton={showCloseButton} onClose={onClose}>
<SignInPanel
onSkip={onClose}
onAuthenticated={onAuthenticated}
@@ -12,6 +12,16 @@ export const root = style({
zIndex: 0,
});
export const closeButton = style({
position: 'fixed',
top: 'calc(env(safe-area-inset-top) + 8px)',
right: 16,
width: 44,
height: 44,
zIndex: 2,
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.18)',
});
export const content = style({
display: 'flex',
flexDirection: 'column',
@@ -1,12 +1,36 @@
import { IconButton } from '@affine/component';
import { CloseIcon } from '@blocksuite/icons/rc';
import type { PropsWithChildren } from 'react';
import { SignInBackground } from './background';
import * as styles from './layout.css';
export const MobileSignInLayout = ({ children }: PropsWithChildren) => {
export const MobileSignInLayout = ({
children,
showCloseButton = false,
onClose,
}: PropsWithChildren<{
showCloseButton?: boolean;
onClose?: () => void;
}>) => {
const shouldShowDismissAffordance = showCloseButton && onClose;
return (
<div className={styles.root}>
<SignInBackground />
{shouldShowDismissAffordance ? (
<IconButton
className={styles.closeButton}
size="24"
variant="solid"
icon={<CloseIcon />}
style={{ borderRadius: 12, padding: 4 }}
onClick={e => {
e.stopPropagation();
onClose?.();
}}
/>
) : null}
<div className={styles.content}>{children}</div>
</div>
);
@@ -0,0 +1,109 @@
/**
* @vitest-environment happy-dom
*/
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import type { ReactNode } from 'react';
import { afterEach, describe, expect, test, vi } from 'vitest';
const setNativeSignIn = (implementation: ReturnType<typeof vi.fn>) => {
Object.defineProperty(window, 'showNativeSignIn', {
configurable: true,
writable: true,
value: implementation,
});
};
vi.mock('@affine/component', () => ({
Modal: ({ open, children }: { open: boolean; children: ReactNode }) =>
open ? <div role="dialog">{children}</div> : null,
}));
vi.mock('@toeverything/theme/v2', () => ({
cssVarV2: () => 'mock-color',
}));
vi.mock('../../components/sign-in', () => ({
MobileSignInPanel: ({
onClose,
server,
initStep,
showCloseButton,
}: {
onClose: () => void;
server?: string;
initStep?: string;
showCloseButton?: boolean;
}) => (
<div>
<span>mobile-sign-in-panel</span>
<span>{server}</span>
<span>{initStep}</span>
<span>{showCloseButton ? 'show-close' : 'hide-close'}</span>
<button onClick={onClose}>close</button>
</div>
),
}));
import { SignInDialog } from './index';
describe('SignInDialog', () => {
afterEach(() => {
cleanup();
delete window.showNativeSignIn;
vi.unstubAllGlobals();
});
test('closes the dialog when native sign-in is cancelled', async () => {
const close = vi.fn();
setNativeSignIn(vi.fn().mockResolvedValue(null));
render(
<SignInDialog
close={close}
server="https://app.affine.pro"
step="signIn"
/>
);
await waitFor(() => {
expect(close).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText('mobile-sign-in-panel')).toBeNull();
});
test('falls back to the web sign-in panel when native bridge is unavailable', async () => {
const close = vi.fn();
render(
<SignInDialog
close={close}
server="https://app.affine.pro"
step="signIn"
/>
);
await waitFor(() => {
expect(screen.getByText('mobile-sign-in-panel')).not.toBeNull();
});
expect(close).not.toHaveBeenCalled();
});
test('closes the dialog after native sign-in succeeds', async () => {
const close = vi.fn();
setNativeSignIn(vi.fn().mockResolvedValue('user-id'));
render(
<SignInDialog
close={close}
server="https://app.affine.pro"
step="signIn"
/>
);
await waitFor(() => {
expect(close).toHaveBeenCalledTimes(1);
});
expect(screen.queryByText('mobile-sign-in-panel')).toBeNull();
});
});
@@ -1,19 +1,78 @@
import { IconButton, Modal, SafeArea } from '@affine/component';
import { Modal } from '@affine/component';
import type { SignInStep } from '@affine/core/components/sign-in';
import type {
DialogComponentProps,
GLOBAL_DIALOG_SCHEMA,
} from '@affine/core/modules/dialogs';
import { CloseIcon } from '@blocksuite/icons/rc';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useEffect, useRef, useState } from 'react';
import { MobileSignInPanel } from '../../components/sign-in';
declare global {
interface Window {
showNativeSignIn?: () => Promise<string | null>;
}
}
export const SignInDialog = ({
close,
server: initialServerBaseUrl,
step,
}: DialogComponentProps<GLOBAL_DIALOG_SCHEMA['sign-in']>) => {
const shouldUseNativeSignIn = step !== 'addSelfhosted';
const [useWebFallback, setUseWebFallback] = useState(!shouldUseNativeSignIn);
const didRequestNativeSignIn = useRef(false);
const closeRef = useRef(close);
useEffect(() => {
closeRef.current = close;
}, [close]);
useEffect(() => {
if (
!shouldUseNativeSignIn ||
useWebFallback ||
didRequestNativeSignIn.current
) {
return;
}
const showNativeSignIn = window.showNativeSignIn;
if (typeof showNativeSignIn !== 'function') {
setUseWebFallback(true);
return;
}
didRequestNativeSignIn.current = true;
let isActive = true;
showNativeSignIn()
.then(accountId => {
if (!isActive) {
return;
}
if (accountId) {
closeRef.current();
return;
}
closeRef.current();
})
.catch((error: unknown) => {
console.error('Failed to show native sign-in', error);
if (isActive) {
setUseWebFallback(true);
}
});
return () => {
isActive = false;
};
}, [shouldUseNativeSignIn, useWebFallback]);
if (!useWebFallback) {
return null;
}
return (
<Modal
fullScreen
@@ -33,23 +92,8 @@ export const SignInDialog = ({
onClose={close}
server={initialServerBaseUrl}
initStep={step as SignInStep}
showCloseButton
/>
<SafeArea
top
style={{ position: 'absolute', top: 0, right: 0, paddingRight: 16 }}
topOffset={8}
>
<IconButton
size="24"
variant="solid"
icon={<CloseIcon />}
style={{ borderRadius: 8, padding: 4 }}
onClick={e => {
e.stopPropagation();
close();
}}
/>
</SafeArea>
</Modal>
);
};
@@ -1,16 +1,18 @@
import { useThemeColorV2, Wrapper } from '@affine/component';
import { EmptyDocs } from '@affine/core/components/affine/empty';
import { useThemeColorV2 } from '@affine/component';
import { usePageHelper } from '@affine/core/blocksuite/block-suite-page-list/utils';
import {
createDocExplorerContext,
DocExplorerContext,
} from '@affine/core/components/explorer/context';
import { DocsExplorer } from '@affine/core/components/explorer/docs-view/docs-list';
import { CollectionRulesService } from '@affine/core/modules/collection-rules';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { inferOpenMode } from '@affine/core/utils';
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect, useState } from 'react';
import { type MouseEvent, useCallback, useEffect, useState } from 'react';
import { Page } from '../../components/page';
import { AllDocsHeader } from '../../views';
import { AllDocsHeader, MobileAllDocsEmptyState } from '../../views';
const AllDocs = () => {
const [explorerContextValue] = useState(() =>
@@ -29,11 +31,22 @@ const AllDocs = () => {
})
);
const collectionRulesService = useService(CollectionRulesService);
const workspace = useService(WorkspaceService).workspace;
const pageHelper = usePageHelper(workspace.docCollection);
const groups = useLiveData(explorerContextValue.groups$);
const isEmpty =
groups.length === 0 ||
(groups.length && groups.every(group => !group.items.length));
const handleCreateDoc = useCallback(
(event: MouseEvent<HTMLButtonElement>) => {
pageHelper.createPage(undefined, {
at: inferOpenMode(event),
});
},
[pageHelper]
);
useEffect(() => {
const subscription = collectionRulesService
.watch({
@@ -65,12 +78,7 @@ const AllDocs = () => {
}, [collectionRulesService, explorerContextValue.groups$]);
if (isEmpty) {
return (
<>
<EmptyDocs absoluteCenter />
<Wrapper height={0} flexGrow={1} />
</>
);
return <MobileAllDocsEmptyState type="docs" onAction={handleCreateDoc} />;
}
return (
@@ -0,0 +1,126 @@
import { LiveData } from '@toeverything/infra';
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { NativeUserIdentifierAuthService } from './native-user-identifier';
import { createNativeUserIdentifierResolver } from './native-user-identifier';
const createAuthService = (
accountId: string | null,
waitForRevalidation: NativeUserIdentifierAuthService['session']['waitForRevalidation']
): NativeUserIdentifierAuthService => ({
session: {
account$: new LiveData(
accountId
? {
id: accountId,
label: 'Test User',
}
: null
),
waitForRevalidation,
},
});
describe('createNativeUserIdentifierResolver', () => {
afterEach(() => {
vi.useRealTimers();
});
test('returns the cached identifier without revalidating', async () => {
const waitForRevalidation = vi.fn().mockResolvedValue(undefined);
const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver();
const authService = createAuthService('user-id', waitForRevalidation);
await expect(resolveCurrentUserIdentifier(authService)).resolves.toBe(
'user-id'
);
expect(waitForRevalidation).not.toHaveBeenCalled();
});
test('reuses the same in-flight revalidation across concurrent lookups', async () => {
let resolveRevalidation: () => void = () => {
throw new Error('Revalidation promise was not created');
};
const waitForRevalidation = vi.fn(
() =>
new Promise<void>(resolve => {
resolveRevalidation = () => {
authService.session.account$.value = {
id: 'user-id',
label: 'Test User',
};
resolve();
};
})
);
const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver({
revalidationCooldownMs: 0,
});
const authService = createAuthService(null, waitForRevalidation);
const firstLookup = resolveCurrentUserIdentifier(authService);
const secondLookup = resolveCurrentUserIdentifier(authService);
expect(waitForRevalidation).toHaveBeenCalledTimes(1);
resolveRevalidation();
await expect(firstLookup).resolves.toBe('user-id');
await expect(secondLookup).resolves.toBe('user-id');
});
test('skips immediate repeat revalidation attempts after an unresolved miss', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-28T00:00:00.000Z'));
const waitForRevalidation = vi.fn().mockResolvedValue(undefined);
const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver({
revalidationCooldownMs: 1000,
revalidationTimeoutMs: 1000,
});
const authService = createAuthService(null, waitForRevalidation);
await expect(resolveCurrentUserIdentifier(authService)).resolves.toBeNull();
await expect(resolveCurrentUserIdentifier(authService)).resolves.toBeNull();
expect(waitForRevalidation).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(1000);
await expect(resolveCurrentUserIdentifier(authService)).resolves.toBeNull();
expect(waitForRevalidation).toHaveBeenCalledTimes(2);
});
test('keeps revalidation cooldown scoped to each auth service', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-28T00:00:00.000Z'));
const firstWaitForRevalidation = vi.fn().mockResolvedValue(undefined);
const secondWaitForRevalidation = vi.fn().mockImplementation(() => {
secondAuthService.session.account$.value = {
id: 'second-user-id',
label: 'Second User',
};
return Promise.resolve();
});
const resolveCurrentUserIdentifier = createNativeUserIdentifierResolver({
revalidationCooldownMs: 1000,
revalidationTimeoutMs: 1000,
});
const firstAuthService = createAuthService(null, firstWaitForRevalidation);
const secondAuthService = createAuthService(
null,
secondWaitForRevalidation
);
await expect(
resolveCurrentUserIdentifier(firstAuthService)
).resolves.toBeNull();
await expect(resolveCurrentUserIdentifier(secondAuthService)).resolves.toBe(
'second-user-id'
);
expect(firstWaitForRevalidation).toHaveBeenCalledTimes(1);
expect(secondWaitForRevalidation).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,94 @@
import type { AuthService } from '../../modules/cloud/services/auth';
export interface NativeUserIdentifierAuthService {
session: Pick<AuthService['session'], 'account$' | 'waitForRevalidation'>;
}
interface NativeUserIdentifierResolverOptions {
revalidationTimeoutMs?: number;
revalidationCooldownMs?: number;
}
const getAccountIdentifier = (identifier: string | null | undefined) => {
const trimmedIdentifier = identifier?.trim();
return trimmedIdentifier ? trimmedIdentifier : null;
};
export const createNativeUserIdentifierResolver = ({
revalidationTimeoutMs = 1500,
revalidationCooldownMs = 1000,
}: NativeUserIdentifierResolverOptions = {}) => {
const revalidationStates = new WeakMap<
NativeUserIdentifierAuthService,
{
promise: Promise<void> | null;
lastStartedAt: number;
}
>();
const getRevalidationState = (
authService: NativeUserIdentifierAuthService
) => {
const existing = revalidationStates.get(authService);
if (existing) {
return existing;
}
const state = {
promise: null,
lastStartedAt: 0,
};
revalidationStates.set(authService, state);
return state;
};
const waitForSessionRevalidation = async (
authService: NativeUserIdentifierAuthService
) => {
const state = getRevalidationState(authService);
const now = Date.now();
if (state.promise) {
await state.promise;
return;
}
if (now - state.lastStartedAt < revalidationCooldownMs) {
return;
}
state.lastStartedAt = now;
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
revalidationTimeoutMs
);
state.promise = authService.session
.waitForRevalidation(controller.signal)
.catch(() => undefined)
.finally(() => {
clearTimeout(timeoutId);
state.promise = null;
});
await state.promise;
};
return async (authService: NativeUserIdentifierAuthService) => {
const cachedIdentifier = getAccountIdentifier(
authService.session.account$.value?.id
);
if (cachedIdentifier) {
return cachedIdentifier;
}
await waitForSessionRevalidation(authService);
return getAccountIdentifier(authService.session.account$.value?.id);
};
};
export const getCurrentNativeUserIdentifier =
createNativeUserIdentifierResolver();
@@ -1,16 +1,51 @@
import { EmptyCollections } from '@affine/core/components/affine/empty';
import { useNavigateHelper } from '@affine/core/components/hooks/use-navigate-helper';
import { CollectionService } from '@affine/core/modules/collection';
import { WorkspaceService } from '@affine/core/modules/workspace';
import { useI18n } from '@affine/i18n';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { CollectionRenameDialog } from '../../../components/navigation/nodes/collection/dialog';
import { MobileAllDocsEmptyState } from '../empty-state';
import { CollectionListItem } from './item';
import { list } from './styles.css';
export const CollectionList = () => {
const t = useI18n();
const collectionService = useService(CollectionService);
const workspace = useService(WorkspaceService).workspace;
const collectionMetas = useLiveData(collectionService.collectionMetas$);
const navigateHelper = useNavigateHelper();
const [showNewCollectionDialog, setShowNewCollectionDialog] = useState(false);
const handleCreateCollection = useCallback(
(name: string) => {
const id = collectionService.createCollection({ name });
navigateHelper.jumpToCollection(workspace.id, id);
},
[collectionService, navigateHelper, workspace.id]
);
if (!collectionMetas.length) {
return <EmptyCollections absoluteCenter />;
return (
<>
<MobileAllDocsEmptyState
type="collections"
onAction={() => setShowNewCollectionDialog(true)}
/>
<CollectionRenameDialog
open={showNewCollectionDialog}
onOpenChange={setShowNewCollectionDialog}
onConfirm={handleCreateCollection}
title={t['com.affine.m.explorer.collection.new-dialog-title']()}
confirmText={t['com.affine.editCollection.save']()}
inputProps={{
placeholder: t['com.affine.editCollectionName.name.placeholder'](),
}}
descRenderer={() => t['com.affine.editCollectionName.createTips']()}
/>
</>
);
}
return (
@@ -0,0 +1,57 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const emptyState = style({
width: '100%',
minHeight:
'calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 44px - 84px)',
boxSizing: 'border-box',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '8px 32px calc(env(safe-area-inset-bottom) + 96px)',
});
export const illustration = style({
width: 96,
height: 96,
objectFit: 'contain',
marginBottom: 24,
userSelect: 'none',
});
export const copy = style({
width: '100%',
maxWidth: 280,
textAlign: 'center',
marginBottom: 28,
});
export const title = style({
margin: 0,
fontSize: 21,
lineHeight: '28px',
fontWeight: 700,
color: cssVarV2('text/primary'),
});
export const description = style({
margin: '10px 0 0',
fontSize: 18,
lineHeight: '24px',
fontWeight: 400,
color: cssVarV2('text/secondary'),
});
export const actionButton = style({
minWidth: 164,
borderRadius: 10,
fontSize: 20,
fontWeight: 600,
boxShadow: `0 8px 18px ${cssVarV2('layer/insideBorder/border')}`,
});
export const actionIcon = style({
fontSize: 20,
});
@@ -0,0 +1,93 @@
import { Button, ThemedImg } from '@affine/component';
import { useI18n } from '@affine/i18n';
import { PlusIcon } from '@blocksuite/icons/rc';
import type { MouseEventHandler } from 'react';
import collectionIllustrationDark from '../../../components/affine/empty/assets/collection-list.dark.png';
import collectionIllustrationLight from '../../../components/affine/empty/assets/collection-list.light.png';
import docsIllustrationDark from '../../../components/affine/empty/assets/docs.dark.png';
import docsIllustrationLight from '../../../components/affine/empty/assets/docs.light.png';
import tagsIllustrationDark from '../../../components/affine/empty/assets/tag-list.dark.png';
import tagsIllustrationLight from '../../../components/affine/empty/assets/tag-list.light.png';
import * as styles from './empty-state.css';
type EmptyStateType = 'docs' | 'collections' | 'tags';
const emptyStateAssets = {
docs: {
illustrationLight: docsIllustrationLight,
illustrationDark: docsIllustrationDark,
},
collections: {
illustrationLight: collectionIllustrationLight,
illustrationDark: collectionIllustrationDark,
},
tags: {
illustrationLight: tagsIllustrationLight,
illustrationDark: tagsIllustrationDark,
},
} satisfies Record<
EmptyStateType,
{
illustrationLight: string;
illustrationDark: string;
}
>;
const emptyStateI18nKeys = {
docs: {
title: 'com.affine.m.explorer.empty.docs.title',
description: 'com.affine.m.explorer.empty.docs.description',
actionLabel: 'com.affine.m.explorer.empty.docs.action',
},
collections: {
title: 'com.affine.m.explorer.empty.collections.title',
description: 'com.affine.m.explorer.empty.collections.description',
actionLabel: 'com.affine.m.explorer.empty.collections.action',
},
tags: {
title: 'com.affine.m.explorer.empty.tags.title',
description: 'com.affine.m.explorer.empty.tags.description',
actionLabel: 'com.affine.m.explorer.empty.tags.action',
},
} as const;
export const MobileAllDocsEmptyState = ({
type,
onAction,
}: {
type: EmptyStateType;
onAction: MouseEventHandler<HTMLButtonElement>;
}) => {
const t = useI18n();
const assets = emptyStateAssets[type];
const copyKeys = emptyStateI18nKeys[type];
const title = t[copyKeys.title]();
const description = t[copyKeys.description]();
const actionLabel = t[copyKeys.actionLabel]();
return (
<section className={styles.emptyState} aria-label={title}>
<ThemedImg
draggable={false}
className={styles.illustration}
lightSrc={assets.illustrationLight}
darkSrc={assets.illustrationDark}
/>
<div className={styles.copy}>
<p className={styles.title}>{title}</p>
<p className={styles.description}>{description}</p>
</div>
<Button
variant="primary"
size="extraLarge"
className={styles.actionButton}
prefix={<PlusIcon />}
prefixClassName={styles.actionIcon}
onClick={onAction}
>
{actionLabel}
</Button>
</section>
);
};
@@ -1,3 +1,4 @@
export * from './collection';
export * from './empty-state';
export * from './header';
export * from './tag';
@@ -1,16 +1,41 @@
import { EmptyTags } from '@affine/core/components/affine/empty';
import { TagService } from '@affine/core/modules/tag';
import { useLiveData, useService } from '@toeverything/infra';
import { useCallback, useState } from 'react';
import { TagRenameDialog } from '../../../components/navigation/nodes/tag/dialog';
import { MobileAllDocsEmptyState } from '../empty-state';
import { TagItem } from './item';
import { list } from './styles.css';
export const TagList = () => {
const tagList = useService(TagService).tagList;
const tagService = useService(TagService);
const tagList = tagService.tagList;
const tags = useLiveData(tagList.tags$);
const [showNewTagDialog, setShowNewTagDialog] = useState(false);
const handleCreateTag = useCallback(
(name: string, color: string) => {
setShowNewTagDialog(false);
tagList.createTag(name, color);
},
[tagList]
);
if (!tags.length) {
return <EmptyTags absoluteCenter />;
return (
<>
<MobileAllDocsEmptyState
type="tags"
onAction={() => setShowNewTagDialog(true)}
/>
<TagRenameDialog
open={showNewTagDialog}
onOpenChange={setShowNewTagDialog}
onConfirm={handleCreateTag}
enableAnimation
/>
</>
);
}
return (
@@ -10,8 +10,6 @@ export { WorkspaceDialogService } from './services/workspace-dialog';
export type { DialogComponentProps } from './types';
export function configureDialogModule(framework: Framework) {
framework
.service(GlobalDialogService)
.scope(WorkspaceScope)
.service(WorkspaceDialogService);
framework.service(GlobalDialogService);
framework.scope(WorkspaceScope).service(WorkspaceDialogService);
}