mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-30 13:20:45 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user