-
Notifications
You must be signed in to change notification settings - Fork 2.7k
Add T3 Connect onboarding for mobile and web #3765
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 |
|---|---|---|
| @@ -0,0 +1,111 @@ | ||
| import { NativeHeaderToolbar } from "../../native/StackHeader"; | ||
| import { useAuth } from "@clerk/expo"; | ||
| import { useNavigation } from "@react-navigation/native"; | ||
| import { useCallback, 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() { | ||
| 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]); | ||
|
|
||
| const handleDontShowAgain = useCallback(() => { | ||
| if (userId) { | ||
| void (async () => { | ||
| const result = await settlePromise(() => optOutOfConnectOnboarding(userId)); | ||
| reportAtomCommandResult(result, { label: "connect onboarding opt-out" }); | ||
| })(); | ||
| } | ||
| navigation.goBack(); | ||
| }, [navigation, 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. Opt-out save races navigationLow Severity
Reviewed by Cursor Bugbot for commit 4f648f1. Configure here. |
||
|
|
||
| return ( | ||
| <View collapsable={false} className="flex-1 bg-sheet"> | ||
| <NativeHeaderToolbar placement="right"> | ||
| <NativeHeaderToolbar.Button icon="xmark" onPress={handleClose} separateBackground /> | ||
| </NativeHeaderToolbar> | ||
| <ScrollView | ||
| 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} | ||
|
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 blocks refresh retryMedium Severity Onboarding hides the Additional Locations (1)Reviewed by Cursor Bugbot for commit 593aa69. Configure here. |
||
| /> | ||
| ) : ( | ||
| <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,45 @@ | ||
| import { useAtomValue } from "@effect/atom-react"; | ||
| import { useNavigation } from "@react-navigation/native"; | ||
| import { useEffect } from "react"; | ||
|
|
||
| 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; | ||
| const timer = setTimeout(() => { | ||
| void (async () => { | ||
| // A failed preference read prefers showing the sheet. | ||
| const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false); | ||
| if (cancelled) { | ||
| return; | ||
| } | ||
| clearConnectOnboardingRequest(); | ||
| if (!optedOut) { | ||
| navigation.navigate("ConnectOnboarding"); | ||
| } | ||
| })(); | ||
|
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. Mobile onboarding opens after sign-outMedium Severity The Reviewed by Cursor Bugbot for commit 4f648f1. Configure here. |
||
| }, PRESENT_ONBOARDING_DELAY_MS); | ||
| return () => { | ||
| cancelled = true; | ||
| clearTimeout(timer); | ||
| }; | ||
| }, [navigation, requestedAccountId]); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| import { loadPreferences, savePreferencesPatch } 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> { | ||
|
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
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| const preferences = await loadPreferences(); | ||
| const optedOut = preferences.connectOnboardingOptOutAccounts ?? []; | ||
| if (optedOut.includes(accountId)) { | ||
| return; | ||
| } | ||
| await savePreferencesPatch({ 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 new root-level
ConnectOnboardingroute rendersConnectOnboardingRouteScreen, which returnsnullwhenhasCloudPublicConfig()isfalse. Navigating toconnect-onboarding(via deep link or direct navigation) on builds without cloud config opens an empty form sheet with no content and no way to dismiss or redirect — the user is stuck. UnlikeSettingsAuthRouteScreenand other config-gated screens, this route has no fallback redirect. Consider redirecting toHome(or another valid route) when the cloud config is unavailable, or guarding the route so it is not reachable in those builds.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: