Skip to content

Commit 2bfca39

Browse files
authored
feat: enhance mobile UX, improve auth flow, and add ScrollDirectionProvider (#1451)
* fix: playwright profile test, you can send to yourself. * fix: update IconUSDC for native app * feat: add ripgrep to unstable tools in flake.nix * feat: add ScrollDirectionProvider for efficient scroll tracking in React Native and Next.js applications * feat: implement token validation to enhance session management in onboarding and account creation processes * feat: document platform-specific code handling for web and native differences in component implementation * feat: enhance mobile UX by fixing layout issues, improving auth flow, and refining onboarding and token activity screens * feat: improve URL validation in swap onboarding tests for better reliability and clearer expectations on navigation outcomes
1 parent 312a473 commit 2bfca39

File tree

15 files changed

+763
-210
lines changed

15 files changed

+763
-210
lines changed

CLAUDE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,27 @@ It is acceptable to vary styles to avoid making too many changes at once.
2828

2929
Assume the style is enforced by a linter and formatter.
3030

31+
### Platform-Specific Code
32+
33+
This project uses a platform-specific extension pattern to handle web vs. native differences:
34+
35+
1. **File naming convention**:
36+
- Base component: `ComponentName.tsx` - shared logic or web-specific implementation
37+
- Native override: `ComponentName.native.tsx` - React Native specific implementation
38+
39+
2. **When to create platform-specific files**:
40+
- When UI components need different native implementations
41+
- When using platform-specific APIs or components
42+
- When optimizing for different platform performance characteristics
43+
- When handling platform-specific UX patterns
44+
45+
3. **Example pattern** (TokenActivityFeed):
46+
- Web version uses RecyclerList (virtualized web list)
47+
- Native version uses FlatList (React Native's optimized list)
48+
- Components maintain the same API while using platform-optimized implementations
49+
50+
When developing new features, consider whether platform-specific implementations are needed and follow this established pattern.
51+
3152
### Comments
3253

3354
Focus comments solely on explaining the code's functionality and design choices, not the history of how the code was changed during our session. Ensure final code does not contain comments related to the debugging steps or conversational edits.

apps/expo/PLAN.md

Lines changed: 140 additions & 138 deletions
Large diffs are not rendered by default.

apps/expo/app/(auth)/_layout.tsx

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,13 @@ import { Anchor, Container, ScrollView, YStack } from '@my/ui'
22
import { useLink } from 'solito/link'
33
import { IconSendLogo } from 'app/components/icons'
44
import { Slot, Stack } from 'expo-router'
5+
import { useSafeAreaInsets } from 'react-native-safe-area-context'
6+
import { useHeaderHeight } from '@react-navigation/elements'
57

68
export default function AuthLayout() {
9+
const { top } = useSafeAreaInsets()
10+
const headerHeight = useHeaderHeight()
11+
712
return (
813
<>
914
<Stack.Screen options={{ headerShown: false }} />
@@ -16,11 +21,29 @@ export default function AuthLayout() {
1621
}}
1722
flex={1}
1823
>
19-
<YStack ai="center" f={1} position="relative">
20-
<Anchor {...useLink({ href: '/' })} mx="auto" position="absolute" top={'$4'}>
24+
<YStack ai="center" f={1} position="relative" px="$4">
25+
<Anchor
26+
{...useLink({ href: '/' })}
27+
mx="auto"
28+
my="$4"
29+
position="absolute"
30+
top={headerHeight || top || '$4'}
31+
>
2132
<IconSendLogo size={'$4'} color={'$color12'} />
2233
</Anchor>
23-
<ScrollView pt="$2" mt="$14" f={1} contentContainerStyle={{ flexGrow: 1 }}>
34+
<ScrollView
35+
pt="$2"
36+
mt="$14"
37+
f={1}
38+
w="100%"
39+
showsVerticalScrollIndicator={false}
40+
contentContainerStyle={{
41+
flexGrow: 1,
42+
paddingTop: 60, // Space for logo
43+
justifyContent: 'center',
44+
width: '100%',
45+
}}
46+
>
2447
<Slot />
2548
</ScrollView>
2649
</YStack>

docs/scroll-direction-provider.md

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
# ScrollDirectionProvider
2+
3+
The `ScrollDirectionProvider` provides scroll direction tracking and scroll position information for React Native and Next.js applications. It detects whether users are scrolling up or down, and also tracks if they've reached the end of scrollable content.
4+
5+
## Overview
6+
7+
This provider creates a React context that exposes:
8+
9+
1. Current scroll direction (up/down/null)
10+
2. Whether the user has scrolled to the end of content
11+
3. Scroll event handlers to attach to scrollable components
12+
4. A ref for ScrollView (when needed)
13+
14+
## Use Cases
15+
16+
- Show/hide UI elements based on scroll direction (e.g., navigation bars)
17+
- Load more content when user scrolls to the end (infinite scrolling)
18+
- Persist scroll position between route changes (web only)
19+
- Track scroll positions for analytics or user experience improvements
20+
21+
## Basic Usage
22+
23+
### Setup
24+
25+
The provider should be included in your app's provider tree:
26+
27+
```tsx
28+
import { ScrollDirectionProvider } from 'app/provider/scroll'
29+
30+
function App() {
31+
return (
32+
<ScrollDirectionProvider>
33+
{/* Your app components */}
34+
</ScrollDirectionProvider>
35+
)
36+
}
37+
```
38+
39+
### Using the hook
40+
41+
Use the `useScrollDirection` hook to access scroll information:
42+
43+
```tsx
44+
import { useScrollDirection } from 'app/provider/scroll'
45+
46+
function MyComponent() {
47+
const { direction, isAtEnd, onScroll, onContentSizeChange, ref } = useScrollDirection()
48+
49+
// Hide navigation when scrolling down
50+
useEffect(() => {
51+
if (direction === 'down') {
52+
// Hide navigation
53+
} else if (direction === 'up') {
54+
// Show navigation
55+
}
56+
}, [direction])
57+
58+
// Load more content when reaching the end
59+
useEffect(() => {
60+
if (isAtEnd) {
61+
loadMoreContent()
62+
}
63+
}, [isAtEnd])
64+
65+
return (
66+
<ScrollView
67+
ref={ref}
68+
onScroll={onScroll}
69+
onContentSizeChange={onContentSizeChange}
70+
scrollEventThrottle={16} // Recommended for smooth tracking
71+
>
72+
{/* Scrollable content */}
73+
</ScrollView>
74+
)
75+
}
76+
```
77+
78+
## API Reference
79+
80+
### ScrollDirectionProvider
81+
82+
```tsx
83+
<ScrollDirectionProvider>
84+
{children}
85+
</ScrollDirectionProvider>
86+
```
87+
88+
Wraps your application to provide scroll direction context.
89+
90+
### useScrollDirection
91+
92+
```tsx
93+
const {
94+
direction,
95+
isAtEnd,
96+
onScroll,
97+
onContentSizeChange,
98+
ref
99+
} = useScrollDirection()
100+
```
101+
102+
Returns:
103+
104+
| Property | Type | Description |
105+
|----------|------|-------------|
106+
| `direction` | `'up'` \| `'down'` \| `null` | Current scroll direction or `null` if not scrolling |
107+
| `isAtEnd` | `boolean` | `true` if user has scrolled to the end of content |
108+
| `onScroll` | `ScrollViewProps['onScroll']` | Event handler to attach to ScrollView |
109+
| `onContentSizeChange` | `ScrollViewProps['onContentSizeChange']` | Event handler to attach to ScrollView |
110+
| `ref` | `React.RefObject<ScrollView>` | Ref to attach to ScrollView |
111+
112+
## Platform-Specific Behavior
113+
114+
### Web (Next.js)
115+
116+
On web, the provider also:
117+
- Tracks scroll positions by route
118+
- Restores scroll position when navigating between routes
119+
- Uses Next.js router for route tracking
120+
121+
### Native (React Native)
122+
123+
On native, the provider:
124+
- Tracks current scroll direction
125+
- Detects when user has reached end of content
126+
- Does not persist scroll positions between screens (navigation handled differently)
127+
128+
## Examples
129+
130+
### Hiding Bottom Navigation on Scroll
131+
132+
```tsx
133+
import { useScrollDirection } from 'app/provider/scroll'
134+
import { XStack } from 'tamagui'
135+
136+
export const BottomNavBar = () => {
137+
const { direction } = useScrollDirection()
138+
139+
return (
140+
<XStack
141+
bottom={direction === 'down' ? -80 : 0} // Hide when scrolling down
142+
animation="200ms"
143+
animateOnly={['bottom']}
144+
// Other styling props
145+
>
146+
{/* Navigation content */}
147+
</XStack>
148+
)
149+
}
150+
```
151+
152+
### Infinite Scroll Implementation
153+
154+
```tsx
155+
import { useScrollDirection } from 'app/provider/scroll'
156+
import { FlatList } from 'react-native'
157+
158+
export const InfiniteList = ({ fetchNextPage, hasNextPage, isFetchingNextPage, data }) => {
159+
const { isAtEnd } = useScrollDirection()
160+
161+
useEffect(() => {
162+
if (isAtEnd && hasNextPage && !isFetchingNextPage) {
163+
fetchNextPage()
164+
}
165+
}, [isAtEnd, hasNextPage, fetchNextPage, isFetchingNextPage])
166+
167+
return (
168+
<FlatList
169+
data={data}
170+
renderItem={({ item }) => (
171+
// Render items
172+
)}
173+
ListFooterComponent={
174+
isFetchingNextPage ? <LoadingIndicator /> : null
175+
}
176+
/>
177+
)
178+
}
179+
```
180+
181+
## Implementation Notes
182+
183+
- A scroll threshold (default: 50px) determines when to trigger direction changes
184+
- Both component dimensions and scroll offsets are tracked to determine end-of-content
185+
- Performance optimized with refs for frequently changing values
186+
- Context value is memoized to prevent unnecessary re-renders

flake.nix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@
6565
pkgs.unstable.bun
6666
pkgs.unstable.tilt
6767
pkgs.unstable.temporal-cli
68+
pkgs.unstable.ripgrep
6869
] # macOS-specific tools
6970
++ (pkgs.lib.optionals pkgs.stdenv.isDarwin [
7071
pkgs.unstable.darwin.xcode_16_3

packages/app/components/icons/IconUSDC.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ColorTokens } from '@my/ui'
22
import { type IconProps, themed } from '@tamagui/helpers-icon'
33
import { memo } from 'react'
4-
import { Defs, G, Path, Rect, Svg } from 'react-native-svg'
4+
import { Defs, G, Path, Rect, Svg, ClipPath } from 'react-native-svg'
55

66
const Usdc = (props) => {
77
const { size, color, ...rest } = props
@@ -28,9 +28,9 @@ const Usdc = (props) => {
2828
/>
2929
</G>
3030
<Defs>
31-
<clipPath id="clip0_265_2602">
31+
<ClipPath id="clip0_265_2602">
3232
<Rect width="32" height="32" fill="white" />
33-
</clipPath>
33+
</ClipPath>
3434
</Defs>
3535
</Svg>
3636
)

packages/app/features/auth/onboarding/onboarding-form.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const OnboardingSchema = z.object({
3232

3333
export const OnboardingForm = () => {
3434
const sendAccountCreate = api.sendAccount.create.useMutation()
35-
const { user } = useUser()
35+
const { user, validateToken } = useUser()
3636
const form = useForm<z.infer<typeof OnboardingSchema>>()
3737
const sendAccount = useSendAccount()
3838
const { replace } = useRouter()
@@ -42,8 +42,35 @@ export const OnboardingForm = () => {
4242

4343
const [errorMessage, setErrorMessage] = useState<string>()
4444

45+
// Validate the token when this component mounts
46+
useEffect(() => {
47+
async function checkTokenValidity() {
48+
// Only continue if we have what appears to be a user
49+
if (!user?.id) {
50+
replace('/')
51+
return
52+
}
53+
54+
// Validate token to ensure it's still valid
55+
const isValid = await validateToken()
56+
if (!isValid) {
57+
// Token validation handles redirect in useUser
58+
return
59+
}
60+
}
61+
62+
checkTokenValidity()
63+
}, [user, validateToken, replace])
64+
4565
async function createAccount({ accountName }: z.infer<typeof OnboardingSchema>) {
4666
try {
67+
// First, validate token
68+
const isTokenValid = await validateToken()
69+
if (!isTokenValid) {
70+
// Token validation already handles redirection
71+
return
72+
}
73+
4774
assert(!!user?.id, 'No user id')
4875

4976
// double check that the user has not already created a send account before creating a passkey
@@ -84,7 +111,27 @@ export const OnboardingForm = () => {
84111
})
85112
} catch (error) {
86113
console.error('Error creating account', error)
87-
const message = error?.message.split('.')[0] ?? 'Unknown error'
114+
115+
// Check for authentication errors
116+
if (error.response?.status === 401 || error.response?.status === 403) {
117+
setErrorMessage('Session expired. Redirecting to sign in...')
118+
setTimeout(() => {
119+
replace('/')
120+
}, 1500)
121+
return
122+
}
123+
124+
const message = error?.message?.split('.')[0] ?? 'Unknown error'
125+
126+
// Check for "No user id" which means token is invalid
127+
if (message.includes('No user id')) {
128+
setErrorMessage('Session expired. Redirecting to sign in...')
129+
setTimeout(() => {
130+
replace('/')
131+
}, 1500)
132+
return
133+
}
134+
88135
setErrorMessage(message)
89136
form.setError('accountName', { type: 'custom' })
90137
}
@@ -96,6 +143,7 @@ export const OnboardingForm = () => {
96143
replace('/') // redirect to home page if account already exists
97144
}
98145
}, [sendAccount.data?.address, replace])
146+
99147
const renderAfterContent = useCallback(
100148
({ submit }: { submit: () => void }) => (
101149
<>
@@ -154,6 +202,7 @@ export const OnboardingForm = () => {
154202
[errorMessage]
155203
)
156204

205+
// If we're not in the client, or the user isn't available, don't render
157206
if (!isClient) return null
158207

159208
return (

0 commit comments

Comments
 (0)