-
Notifications
You must be signed in to change notification settings - Fork 3.2k
Revert "Revert "Add T3 Connect onboarding for mobile and web"" #3777
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,6 +18,7 @@ import { | |
| setAgentAwarenessRelayTokenProvider, | ||
| unregisterAgentAwarenessDeviceForCurrentUser, | ||
| } from "../agent-awareness/remoteRegistration"; | ||
| import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; | ||
| import { resolveCloudPublicConfig, resolveRelayClerkTokenOptions } from "./publicConfig"; | ||
|
|
||
| function resetManagedRelayTokenCache() { | ||
|
|
@@ -67,6 +68,19 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | |
| const nextAccount = isSignedIn && userId ? userId : null; | ||
| observedAccountRef.current = nextAccount; | ||
|
|
||
| // Every sign-in or account switch that completes during this session (a | ||
| // cold start observes undefined → account and must not re-prompt) requests | ||
| // the T3 Connect onboarding sheet — account transitions clear the | ||
| // connected environments, so each new session starts with no devices to | ||
| // reach. The request itself is issued after the cleanup transition inside | ||
| // activateSession, so the sheet never lists the previous account's | ||
| // environments; sign-out drops any not-yet-presented request instead. | ||
| const isAccountTransition = | ||
| previousObservedAccount !== undefined && previousObservedAccount !== nextAccount; | ||
| if (isAccountTransition && nextAccount === null) { | ||
| clearConnectOnboardingRequest(); | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| const queueAccountCleanup = ( | ||
| previous: { | ||
| readonly userId: string; | ||
|
|
@@ -114,6 +128,9 @@ function CloudAuthBridge(props: { readonly children: ReactNode }) { | |
| } | ||
| previousTokenProviderRef.current = { userId, provider: tokenProvider }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Medium On an account switch, 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| activateCloudRelayAccount(userId, tokenProvider); | ||
| if (isAccountTransition) { | ||
| requestConnectOnboarding(userId); | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Onboarding request dropped on effect rerunHigh Severity Deferring Reviewed by Cursor Bugbot for commit cac134c. Configure here. |
||
| }; | ||
| const activateAfterTransition = (transition: Promise<void>) => { | ||
| void (async () => { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,129 @@ | ||
| import { NativeHeaderToolbar } from "../../native/StackHeader"; | ||
| import { useAuth } from "@clerk/expo"; | ||
| import { StackActions, useNavigation } from "@react-navigation/native"; | ||
| import { useCallback, useEffect, useState } from "react"; | ||
| import { Pressable, RefreshControl, ScrollView, View } from "react-native"; | ||
| import { useSafeAreaInsets } from "react-native-safe-area-context"; | ||
|
|
||
| import { reportAtomCommandResult, settlePromise } from "@t3tools/client-runtime/state/runtime"; | ||
| import { AppText as Text } from "../../components/AppText"; | ||
| import { useRemoteConnections } from "../../state/use-remote-environment-registry"; | ||
| import { CloudEnvironmentRows } from "../connection/CloudEnvironmentRows"; | ||
| import { splitEnvironmentSections } from "../connection/environmentSections"; | ||
| import { useConnectionController } from "../connection/useConnectionController"; | ||
| import { optOutOfConnectOnboarding } from "./connectOnboardingOptOut"; | ||
| import { hasCloudPublicConfig } from "./publicConfig"; | ||
|
|
||
| /** | ||
| * Post-sign-in onboarding sheet for T3 Connect. Mobile never publishes | ||
| * environments itself — it consumes ones published elsewhere — so this simply | ||
| * surfaces the account's T3 Connect environments right after sign-in so every | ||
| * device can be connected in one go. It shows on every sign-in: sign-out | ||
| * clears the connected environments, so each new session starts from zero. | ||
| */ | ||
| export function ConnectOnboardingRouteScreen() { | ||
| const navigation = useNavigation(); | ||
|
|
||
| // The route is deep-linkable; without cloud config the sheet would present | ||
| // empty with no chrome to dismiss it, so bail back out instead. | ||
| useEffect(() => { | ||
| if (hasCloudPublicConfig()) { | ||
| return; | ||
| } | ||
| if (navigation.canGoBack()) { | ||
| navigation.goBack(); | ||
| } else { | ||
| navigation.dispatch(StackActions.replace("Home")); | ||
| } | ||
| }, [navigation]); | ||
|
|
||
|
cursor[bot] marked this conversation as resolved.
|
||
| return hasCloudPublicConfig() ? <ConfiguredConnectOnboardingRouteScreen /> : null; | ||
| } | ||
|
|
||
| function ConfiguredConnectOnboardingRouteScreen() { | ||
| const navigation = useNavigation(); | ||
| const insets = useSafeAreaInsets(); | ||
| const { isSignedIn, userId } = useAuth({ treatPendingAsSignedOut: false }); | ||
| const { connectedEnvironments, onReconnectEnvironment } = useRemoteConnections(); | ||
| const { refreshRelayEnvironments } = useConnectionController(); | ||
| const { connectedCloudEnvironments } = splitEnvironmentSections({ | ||
| connectedEnvironments, | ||
| cloudEnvironments: null, | ||
| }); | ||
|
|
||
| // Pull-to-refresh tracks its own spinner instead of discovery's refreshing | ||
| // flag, so background refreshes (e.g. the sign-in one) don't yank the | ||
| // content down. | ||
| const [isPullRefreshing, setIsPullRefreshing] = useState(false); | ||
| const handlePullRefresh = useCallback(() => { | ||
| void (async () => { | ||
| setIsPullRefreshing(true); | ||
| await refreshRelayEnvironments(); | ||
| setIsPullRefreshing(false); | ||
| })(); | ||
| }, [refreshRelayEnvironments]); | ||
|
|
||
| const handleClose = useCallback(() => { | ||
| navigation.goBack(); | ||
| }, [navigation]); | ||
|
|
||
| // Persist before dismissing so a quick sign-out/sign-in cannot race ahead | ||
| // of the preference write; the write is a local secure-store update. | ||
| const handleDontShowAgain = useCallback(() => { | ||
| void (async () => { | ||
| if (userId) { | ||
| const result = await settlePromise(() => optOutOfConnectOnboarding(userId)); | ||
| reportAtomCommandResult(result, { label: "connect onboarding opt-out" }); | ||
| } | ||
| navigation.goBack(); | ||
| })(); | ||
| }, [navigation, userId]); | ||
|
|
||
| return ( | ||
| <View collapsable={false} className="flex-1 bg-sheet"> | ||
| <NativeHeaderToolbar placement="right"> | ||
| <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground /> | ||
| </NativeHeaderToolbar> | ||
| <ScrollView | ||
| alwaysBounceVertical | ||
| contentInsetAdjustmentBehavior="automatic" | ||
| showsVerticalScrollIndicator={false} | ||
| style={{ flex: 1 }} | ||
| contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }} | ||
| contentContainerStyle={{ | ||
| gap: 16, | ||
| paddingHorizontal: 20, | ||
| paddingTop: 16, | ||
| }} | ||
| refreshControl={ | ||
| <RefreshControl refreshing={isPullRefreshing} onRefresh={handlePullRefresh} /> | ||
| } | ||
| > | ||
| {isSignedIn ? ( | ||
| <CloudEnvironmentRows | ||
| connectedCloudEnvironments={connectedCloudEnvironments} | ||
| onReconnectEnvironment={onReconnectEnvironment} | ||
| showHeader={false} | ||
| /> | ||
| ) : ( | ||
| <View collapsable={false} className="rounded-[24px] bg-card p-5"> | ||
| <Text className="text-sm leading-normal text-foreground-muted"> | ||
| Sign in to your T3 account to set up T3 Connect. | ||
| </Text> | ||
| </View> | ||
| )} | ||
|
|
||
| {userId ? ( | ||
| <Pressable | ||
| accessibilityRole="button" | ||
| hitSlop={8} | ||
| onPress={handleDontShowAgain} | ||
| className="items-center py-1 active:opacity-70" | ||
| > | ||
| <Text className="text-xs text-foreground-muted">{"Don't show this again"}</Text> | ||
| </Pressable> | ||
| ) : null} | ||
| </ScrollView> | ||
| </View> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| import { Atom } from "effect/unstable/reactivity"; | ||
|
|
||
| import { appAtomRegistry } from "../../state/atom-registry"; | ||
|
|
||
| // Signals RootStackLayout (inside the navigation tree) that an in-session | ||
| // sign-in just completed. Holds the account id so a sign-out between the | ||
| // request and the navigation cannot present the sheet for the wrong account. | ||
| export const connectOnboardingRequestAtom = Atom.make<string | null>(null).pipe( | ||
| Atom.keepAlive, | ||
| Atom.withLabel("mobile:connect-onboarding-request"), | ||
| ); | ||
|
|
||
| /** | ||
| * Requests the onboarding sheet for the given account. Sign-out clears the | ||
| * connected environments, so onboarding runs on every in-session sign-in — | ||
| * each new session starts with no connected devices. | ||
| */ | ||
| export function requestConnectOnboarding(accountId: string): void { | ||
| appAtomRegistry.set(connectOnboardingRequestAtom, accountId); | ||
| } | ||
|
|
||
| export function clearConnectOnboardingRequest(): void { | ||
| appAtomRegistry.set(connectOnboardingRequestAtom, null); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { useAtomValue } from "@effect/atom-react"; | ||
| import { useNavigation } from "@react-navigation/native"; | ||
| import { useEffect } from "react"; | ||
|
|
||
| import { appAtomRegistry } from "../../state/atom-registry"; | ||
| import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; | ||
| import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; | ||
|
|
||
| // Sign-in happens inside the Settings sheet; give its detent/session-state | ||
| // transitions a beat to settle before presenting another formSheet on top. | ||
| const PRESENT_ONBOARDING_DELAY_MS = 600; | ||
|
|
||
| /** | ||
| * Consumes the onboarding request inside the navigation tree (RootStackLayout) | ||
| * and presents the onboarding formSheet. Lives apart from connectOnboarding.ts | ||
| * so non-navigation consumers (CloudAuthProvider) do not pull | ||
| * @react-navigation into their module graph. | ||
| */ | ||
| export function useConnectOnboardingNavigation(): void { | ||
| const navigation = useNavigation(); | ||
| const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); | ||
|
|
||
| useEffect(() => { | ||
| if (requestedAccountId === null) { | ||
| return; | ||
| } | ||
| let cancelled = false; | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| const timer = setTimeout(() => { | ||
| void (async () => { | ||
| // A failed preference read prefers showing the sheet. | ||
| const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false); | ||
| // The cancelled flag covers effect re-runs, but a sign-out can clear | ||
| // the request atom moments before this render commits — re-check the | ||
| // atom so a stale request never presents the sheet. | ||
| if (cancelled || appAtomRegistry.get(connectOnboardingRequestAtom) !== requestedAccountId) { | ||
| return; | ||
| } | ||
| clearConnectOnboardingRequest(); | ||
| if (!optedOut) { | ||
| navigation.navigate("ConnectOnboarding"); | ||
| } | ||
| })(); | ||
| }, PRESENT_ONBOARDING_DELAY_MS); | ||
| return () => { | ||
| cancelled = true; | ||
| clearTimeout(timer); | ||
| }; | ||
| }, [navigation, requestedAccountId]); | ||
|
cursor[bot] marked this conversation as resolved.
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { loadPreferences, updatePreferences } from "../../lib/storage"; | ||
|
|
||
| // Lives apart from connectOnboarding.ts so CloudAuthProvider (which imports | ||
| // the request signal) never pulls lib/storage — expo-secure-store — into its | ||
| // module graph; that breaks CloudAuthProvider.test.ts suite loading. | ||
|
|
||
| /** Whether the account chose "Don't show this again". */ | ||
| export async function isConnectOnboardingOptedOut(accountId: string): Promise<boolean> { | ||
| const preferences = await loadPreferences(); | ||
| return preferences.connectOnboardingOptOutAccounts?.includes(accountId) ?? false; | ||
| } | ||
|
|
||
| /** Persists "Don't show this again" for the account. */ | ||
| export async function optOutOfConnectOnboarding(accountId: string): Promise<void> { | ||
| await updatePreferences((current) => { | ||
| const optedOut = current.connectOnboardingOptOutAccounts ?? []; | ||
| return optedOut.includes(accountId) | ||
| ? {} | ||
| : { connectOnboardingOptOutAccounts: [...optedOut, accountId] }; | ||
| }); | ||
| } |


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
src/Stack.tsx:420The
ConnectOnboardingroute is registered unconditionally inRootStack, butConnectOnboardingRouteScreenrendersnullwhenhasCloudPublicConfig()is false. Builds whereCloudAuthProvideris active (e.g. a Clerk publishable key and relay URL are set) buthasCloudPublicConfig()returns false can still trigger onboarding navigation after sign-in, presenting users with a blank titled form sheet with no content and no way forward. Consider gating the route registration onhasCloudPublicConfig()so navigation toConnectOnboardingis never possible when the screen would render nothing.Also found in 1 other location(s)
apps/mobile/src/features/cloud/ConnectOnboardingRouteScreen.tsx:25🚀 Reply "fix it for me" or copy this AI Prompt for your agent: