mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-01 22:29:44 +08:00
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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+35
-38
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user