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
+275 -81
View File
@@ -8,6 +8,7 @@ import { MobileBackCoordinator } from '@affine/core/mobile/modules/back-coordina
import { HapticProvider } from '@affine/core/mobile/modules/haptics';
import { VirtualKeyboardProvider } from '@affine/core/mobile/modules/virtual-keyboard';
import { router } from '@affine/core/mobile/router';
import { getCurrentNativeUserIdentifier } from '@affine/core/mobile/utils/native-user-identifier';
import { configureCommonModules } from '@affine/core/modules';
import {
AuthProvider,
@@ -20,6 +21,7 @@ import {
ValidatorProvider,
} from '@affine/core/modules/cloud';
import { registerNativePreviewHandlers } from '@affine/core/modules/code-block-preview-renderer';
import { GlobalDialogService } from '@affine/core/modules/dialogs';
import { DocsService } from '@affine/core/modules/doc';
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
import { GlobalContextService } from '@affine/core/modules/global-context';
@@ -30,7 +32,7 @@ import {
configureLocalStorageStateStorageImpls,
NbstoreProvider,
} from '@affine/core/modules/storage';
import { PopupWindowProvider } from '@affine/core/modules/url';
import { PopupWindowProvider, UrlService } from '@affine/core/modules/url';
import { ClientSchemeProvider } from '@affine/core/modules/url/providers/client-schema';
import {
configureBrowserWorkbenchModule,
@@ -43,6 +45,7 @@ import {
import { configureBrowserWorkspaceFlavours } from '@affine/core/modules/workspace-engine';
import { getWorkerUrl } from '@affine/env/worker';
import {
OAuthProviderType,
refreshSubscriptionMutation,
requestApplySubscriptionMutation,
} from '@affine/graphql';
@@ -98,6 +101,19 @@ window.addEventListener('beforeunload', () => {
storeManagerClient.dispose();
});
const waitForSubscriptionRevalidation = async (
subscriptionService: SubscriptionService,
fallbackMessage: string
) => {
await subscriptionService.subscription.waitForRevalidation();
const error = subscriptionService.subscription.error$.value;
if (error) {
throw error instanceof Error
? error
: new Error(getErrorMessage(error, fallbackMessage));
}
};
const future = {
v7_startTransition: true,
} as const;
@@ -147,19 +163,57 @@ framework.impl(VirtualKeyboardProvider, {
let disposeRef = {
dispose: () => {},
};
let viewportDispose = () => {};
let pluginKeyboardHeight = 0;
let pluginKeyboardVisible = false;
const getViewportKeyboardHeight = () => {
const viewport = window.visualViewport;
if (!viewport) {
return 0;
}
return Math.max(
0,
window.innerHeight - viewport.height - viewport.offsetTop
);
};
const emitKeyboardState = () => {
const viewportKeyboardHeight = getViewportKeyboardHeight();
const effectiveKeyboardHeight = Math.max(
pluginKeyboardHeight,
viewportKeyboardHeight
);
callback({
visible: pluginKeyboardVisible || effectiveKeyboardHeight > 0,
height: effectiveKeyboardHeight,
});
};
const viewport = window.visualViewport;
if (viewport) {
const handleViewportChange = () => {
emitKeyboardState();
};
viewport.addEventListener('resize', handleViewportChange);
viewport.addEventListener('scroll', handleViewportChange);
viewportDispose = () => {
viewport.removeEventListener('resize', handleViewportChange);
viewport.removeEventListener('scroll', handleViewportChange);
};
}
Promise.all([
Keyboard.addListener('keyboardWillShow', info => {
callback({
visible: info.keyboardHeight !== 0,
height: info.keyboardHeight,
});
pluginKeyboardVisible = info.keyboardHeight !== 0;
pluginKeyboardHeight = info.keyboardHeight;
emitKeyboardState();
}),
Keyboard.addListener('keyboardWillHide', () => {
callback({
visible: false,
height: 0,
});
pluginKeyboardVisible = false;
pluginKeyboardHeight = 0;
emitKeyboardState();
}),
])
.then(handlers => {
@@ -171,8 +225,11 @@ framework.impl(VirtualKeyboardProvider, {
})
.catch(console.error);
emitKeyboardState();
return () => {
disposeRef.dispose();
viewportDispose();
};
},
});
@@ -236,6 +293,8 @@ framework.impl(NativePaywallProvider, {
});
const frameworkProvider = framework.provider();
let cancelActiveRequestSignIn: (() => void) | null = null;
let activeNativeSignInPromise: Promise<string | null> | null = null;
registerNativePreviewHandlers({
renderMermaidSvg: request => Preview.renderMermaidSvg(request),
@@ -337,7 +396,18 @@ registerNativeImageFilesPicker(async () => {
workspaceRef.dispose();
}
};
(window as any).getCurrentUserIdentifier = () => {
(window as any).getCurrentUserIdentifier = async () => {
const { authService } = getCurrentNativeSignInContext();
return await getCurrentNativeUserIdentifier(authService);
};
(window as any).cancelRequestSignIn = () => {
if (!cancelActiveRequestSignIn) {
return false;
}
cancelActiveRequestSignIn();
return true;
};
const getCurrentNativeSignInContext = () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
const currentServerId = globalContextService.globalContext.serverId.get();
const serversService = frameworkProvider.get(ServersService);
@@ -345,7 +415,119 @@ registerNativeImageFilesPicker(async () => {
const currentServer =
(currentServerId ? serversService.server$(currentServerId).value : null) ??
defaultServerService.server;
return currentServer.account$.value?.id;
const authService = currentServer.scope.get(AuthService);
return { authService, currentServer };
};
(window as any).nativeStartOAuthSignIn = async (
provider: 'Google' | 'Apple'
) => {
const { authService } = getCurrentNativeSignInContext();
const urlService = frameworkProvider.get(UrlService);
const scheme = urlService.getClientScheme();
const oauthProvider =
provider === 'Apple' ? OAuthProviderType.Apple : OAuthProviderType.Google;
const options = await authService.oauthPreflight(
oauthProvider,
scheme ?? 'web'
);
return options.url;
};
(window as any).nativeCheckEmailSignInMethods = async (email: string) => {
const { authService } = getCurrentNativeSignInContext();
const { methods } = await authService.checkUserByEmail(email);
return {
hasPassword: !!methods.password.available,
canUseMagicLink: !!methods.magicLink.available,
};
};
(window as any).nativeSendEmailMagicLink = async (email: string) => {
const { authService } = getCurrentNativeSignInContext();
await authService.sendEmailMagicLink(email);
return true;
};
(window as any).nativeSignInWithMagicLink = async (
email: string,
token: string
) => {
const { authService } = getCurrentNativeSignInContext();
await authService.signInMagicLink(email, token, false);
const session = await authService.session.waitForAuthenticated();
return session.session.account.id;
};
(window as any).nativeSignInWithPassword = async (
email: string,
password: string
) => {
const { authService } = getCurrentNativeSignInContext();
await authService.signInPassword({ email, password });
const session = await authService.session.waitForAuthenticated();
return session.session.account.id;
};
(window as any).nativeOpenSelfHostedSignIn = async () => {
const globalDialogService = frameworkProvider.get(GlobalDialogService);
globalDialogService.open('sign-in', { step: 'addSelfhosted' });
return true;
};
const showNativeSignIn = async () => {
const { authService } = getCurrentNativeSignInContext();
const account = authService.session.account$.value;
if (account?.id) {
return account.id;
}
if (activeNativeSignInPromise) {
const result = await activeNativeSignInPromise;
const authenticatedAccount = authService.session.account$.value;
if (authenticatedAccount?.id) {
return authenticatedAccount.id;
}
return result;
}
let cancelRequestSignIn!: () => void;
const cancelledSignIn = new Promise<{ success: false }>(resolve => {
cancelRequestSignIn = () => resolve({ success: false });
});
cancelActiveRequestSignIn = cancelRequestSignIn;
activeNativeSignInPromise = (async () => {
const result = await Promise.race([
Auth.showNativeSignIn(),
cancelledSignIn,
]);
if (!result.success) {
return null;
}
const authenticatedAccount = authService.session.account$.value;
if (authenticatedAccount?.id) {
return authenticatedAccount.id;
}
const session = await authService.session.waitForAuthenticated();
return session.session.account.id;
})();
try {
return await activeNativeSignInPromise;
} finally {
if (cancelActiveRequestSignIn === cancelRequestSignIn) {
cancelActiveRequestSignIn = null;
}
activeNativeSignInPromise = null;
}
};
(window as any).showNativeSignIn = showNativeSignIn;
(window as any).requestSignIn = async () => {
return await showNativeSignIn();
};
(window as any).getCurrentDocContentInMarkdown = async () => {
const globalContextService = frameworkProvider.get(GlobalContextService);
@@ -451,7 +633,10 @@ registerNativeImageFilesPicker(async () => {
(currentServerId ? serversService.server$(currentServerId).value : null) ??
defaultServerService.server;
const subscriptionService = currentServer.scope.get(SubscriptionService);
await subscriptionService.subscription.waitForRevalidation();
await waitForSubscriptionRevalidation(
subscriptionService,
'Unable to refresh subscription state.'
);
return {
pro: subscriptionService.subscription.pro$.value,
ai: subscriptionService.subscription.ai$.value,
@@ -465,13 +650,14 @@ registerNativeImageFilesPicker(async () => {
const currentServer =
(currentServerId ? serversService.server$(currentServerId).value : null) ??
defaultServerService.server;
await currentServer
.gql({
query: refreshSubscriptionMutation,
})
.catch(console.error);
await currentServer.gql({
query: refreshSubscriptionMutation,
});
const subscriptionService = currentServer.scope.get(SubscriptionService);
subscriptionService.subscription.revalidate();
await waitForSubscriptionRevalidation(
subscriptionService,
'Unable to refresh subscription state.'
);
};
(window as any).requestApplySubscription = async (transactionId: string) => {
const globalContextService = frameworkProvider.get(GlobalContextService);
@@ -481,14 +667,15 @@ registerNativeImageFilesPicker(async () => {
const currentServer =
(currentServerId ? serversService.server$(currentServerId).value : null) ??
defaultServerService.server;
await currentServer
.gql({
query: requestApplySubscriptionMutation,
variables: { transactionId },
})
.catch(console.error);
await currentServer.gql({
query: requestApplySubscriptionMutation,
variables: { transactionId },
});
const subscriptionService = currentServer.scope.get(SubscriptionService);
subscriptionService.subscription.revalidate();
await waitForSubscriptionRevalidation(
subscriptionService,
'Unable to refresh subscription state after purchase.'
);
};
// setup application lifecycle events, and emit application start event
@@ -522,62 +709,58 @@ const notifyAuthenticationError = (error: unknown, fallback: string) => {
});
};
const handleAuthenticationCallback = async (url: string) => {
const urlObj = new URL(url);
if (urlObj.hostname !== 'authentication') {
return;
}
const method = urlObj.searchParams.get('method');
const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false');
const serverBaseUrl = urlObj.searchParams.get('server');
if (!method || (method !== 'magic-link' && method !== 'oauth') || !payload) {
throw new Error('Invalid authentication url');
}
let authService = frameworkProvider
.get(DefaultServerService)
.server.scope.get(AuthService);
if (serverBaseUrl) {
const serversService = frameworkProvider.get(ServersService);
const server = serversService.getServerByBaseUrl(serverBaseUrl);
if (!server) {
throw new Error(
`Authentication callback server not found: ${serverBaseUrl}`
);
}
authService = server.scope.get(AuthService);
}
if (method === 'oauth') {
await authService.signInOauth(
payload.code,
payload.state,
payload.provider
);
} else if (method === 'magic-link') {
await authService.signInMagicLink(payload.email, payload.token);
}
};
(window as any).nativeHandleAuthenticationCallback = async (url: string) => {
await handleAuthenticationCallback(url);
return true;
};
CapacitorApp.addListener('appUrlOpen', ({ url }) => {
// try to close browser if it's open
Browser.close().catch(e => console.error('Failed to close browser', e));
const urlObj = new URL(url);
if (urlObj.hostname === 'authentication') {
const method = urlObj.searchParams.get('method');
const payload = JSON.parse(urlObj.searchParams.get('payload') ?? 'false');
const serverBaseUrl = urlObj.searchParams.get('server');
if (
!method ||
(method !== 'magic-link' && method !== 'oauth') ||
!payload
) {
notifyAuthenticationError(
new Error('Invalid authentication url'),
'Invalid authentication url'
);
return;
}
let authService = frameworkProvider
.get(DefaultServerService)
.server.scope.get(AuthService);
if (serverBaseUrl) {
const serversService = frameworkProvider.get(ServersService);
const server = serversService.getServerByBaseUrl(serverBaseUrl);
if (!server) {
notifyAuthenticationError(
new Error(
`Authentication callback server not found: ${serverBaseUrl}`
),
'Authentication callback server not found'
);
return;
}
authService = server.scope.get(AuthService);
}
if (method === 'oauth') {
authService
.signInOauth(payload.code, payload.state, payload.provider)
.catch(error =>
notifyAuthenticationError(error, 'Failed to sign in with OAuth')
);
} else if (method === 'magic-link') {
authService
.signInMagicLink(payload.email, payload.token)
.catch(error =>
notifyAuthenticationError(error, 'Failed to sign in with magic link')
);
}
}
handleAuthenticationCallback(url).catch(error =>
notifyAuthenticationError(error, 'Failed to handle authentication callback')
);
}).catch(e => {
notifyAuthenticationError(e, 'Failed to handle authentication callback');
});
@@ -587,7 +770,7 @@ AppTrackingTransparency.requestPermission().catch(e => {
});
const KeyboardThemeProvider = () => {
const { resolvedTheme } = useTheme();
const { resolvedTheme, theme } = useTheme();
useEffect(() => {
Keyboard.setStyle({
@@ -603,7 +786,18 @@ const KeyboardThemeProvider = () => {
}, [resolvedTheme]);
useEffect(() => {
const themeMode = resolvedTheme === 'dark' ? 'dark' : 'light';
if (!theme && !resolvedTheme) {
return;
}
const themeMode: 'dark' | 'light' | 'system' =
theme === 'dark' || theme === 'light' || theme === 'system'
? theme
: resolvedTheme === 'dark'
? 'dark'
: resolvedTheme === 'light'
? 'light'
: 'system';
(window as any).getCurrentThemeMode = () => {
return themeMode;
};
@@ -612,7 +806,7 @@ const KeyboardThemeProvider = () => {
}).catch(e => {
console.error(`Failed to sync app theme: ${e}`);
});
}, [resolvedTheme]);
}, [resolvedTheme, theme]);
return null;
};
@@ -1,4 +1,5 @@
export interface AuthPlugin {
showNativeSignIn(): Promise<{ success: boolean }>;
signInMagicLink(options: {
endpoint: string;
email: string;