tangled
alpha
login
or
join now
quilling.dev
/
social-app
7
fork
atom
An ATproto social media client -- with an independent Appview.
7
fork
atom
overview
issues
pulls
pipelines
fix: i can be trusted with force push to main perms
serenity
5 months ago
47e67d9b
1de143de
+129
-682
6 changed files
expand all
collapse all
unified
split
src
screens
Login
index.tsx
state
invites.tsx
queries
invites.ts
view
com
composer
Composer.tsx
util
forms
NativeDropdown.tsx
NativeDropdown.web.tsx
+4
-4
src/screens/Login/index.tsx
···
1
1
-
import React, {useCallback, useMemo, useRef} from 'react'
1
1
+
import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2
2
import {KeyboardAvoidingView} from 'react-native'
3
3
import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
4
4
import {msg} from '@lingui/macro'
···
49
49
acc => acc.did === requestedAccountSwitchTo,
50
50
)
51
51
52
52
-
const [isResolvingService, setIsResolvingService] = React.useState(false)
53
53
-
const [error, setError] = React.useState<string>('')
54
54
-
const [serviceUrl, setServiceUrl] = React.useState<string | undefined>(
52
52
+
const [isResolvingService, setIsResolvingService] = useState(false)
53
53
+
const [error, setError] = useState<string>('')
54
54
+
const [serviceUrl, setServiceUrl] = useState<string | undefined>(
55
55
requestedAccount?.service,
56
56
)
57
57
const [initialHandle, setInitialHandle] = useState(
+59
src/state/invites.tsx
···
1
1
+
import React from 'react'
2
2
+
3
3
+
import * as persisted from '#/state/persisted'
4
4
+
5
5
+
type StateContext = persisted.Schema['invites']
6
6
+
type ApiContext = {
7
7
+
setInviteCopied: (code: string) => void
8
8
+
}
9
9
+
10
10
+
const stateContext = React.createContext<StateContext>(
11
11
+
persisted.defaults.invites,
12
12
+
)
13
13
+
stateContext.displayName = 'InvitesStateContext'
14
14
+
const apiContext = React.createContext<ApiContext>({
15
15
+
setInviteCopied(_: string) {},
16
16
+
})
17
17
+
apiContext.displayName = 'InvitesApiContext'
18
18
+
19
19
+
export function Provider({children}: React.PropsWithChildren<{}>) {
20
20
+
const [state, setState] = React.useState(persisted.get('invites'))
21
21
+
22
22
+
const api = React.useMemo(
23
23
+
() => ({
24
24
+
setInviteCopied(code: string) {
25
25
+
setState(state => {
26
26
+
state = {
27
27
+
...state,
28
28
+
copiedInvites: state.copiedInvites.includes(code)
29
29
+
? state.copiedInvites
30
30
+
: state.copiedInvites.concat([code]),
31
31
+
}
32
32
+
persisted.write('invites', state)
33
33
+
return state
34
34
+
})
35
35
+
},
36
36
+
}),
37
37
+
[setState],
38
38
+
)
39
39
+
40
40
+
React.useEffect(() => {
41
41
+
return persisted.onUpdate('invites', nextInvites => {
42
42
+
setState(nextInvites)
43
43
+
})
44
44
+
}, [setState])
45
45
+
46
46
+
return (
47
47
+
<stateContext.Provider value={state}>
48
48
+
<apiContext.Provider value={api}>{children}</apiContext.Provider>
49
49
+
</stateContext.Provider>
50
50
+
)
51
51
+
}
52
52
+
53
53
+
export function useInvitesState() {
54
54
+
return React.useContext(stateContext)
55
55
+
}
56
56
+
57
57
+
export function useInvitesAPI() {
58
58
+
return React.useContext(apiContext)
59
59
+
}
+65
src/state/queries/invites.ts
···
1
1
+
import {type ComAtprotoServerDefs} from '@atproto/api'
2
2
+
import {useQuery} from '@tanstack/react-query'
3
3
+
4
4
+
import {cleanError} from '#/lib/strings/errors'
5
5
+
import {STALE} from '#/state/queries'
6
6
+
import {useAgent} from '#/state/session'
7
7
+
8
8
+
function isInviteAvailable(invite: ComAtprotoServerDefs.InviteCode): boolean {
9
9
+
return invite.available - invite.uses.length > 0 && !invite.disabled
10
10
+
}
11
11
+
12
12
+
const inviteCodesQueryKeyRoot = 'inviteCodes'
13
13
+
14
14
+
export type InviteCodesQueryResponse = Exclude<
15
15
+
ReturnType<typeof useInviteCodesQuery>['data'],
16
16
+
undefined
17
17
+
>
18
18
+
export function useInviteCodesQuery() {
19
19
+
const agent = useAgent()
20
20
+
return useQuery({
21
21
+
staleTime: STALE.MINUTES.FIVE,
22
22
+
queryKey: [inviteCodesQueryKeyRoot],
23
23
+
queryFn: async () => {
24
24
+
const res = await agent.com.atproto.server
25
25
+
.getAccountInviteCodes({})
26
26
+
.catch(e => {
27
27
+
if (cleanError(e) === 'Bad token scope') {
28
28
+
return null
29
29
+
} else {
30
30
+
throw e
31
31
+
}
32
32
+
})
33
33
+
34
34
+
if (res === null) {
35
35
+
return {
36
36
+
disabled: true,
37
37
+
all: [],
38
38
+
available: [],
39
39
+
used: [],
40
40
+
}
41
41
+
}
42
42
+
43
43
+
if (!res.data?.codes) {
44
44
+
throw new Error(`useInviteCodesQuery: no codes returned`)
45
45
+
}
46
46
+
47
47
+
const available = res.data.codes.filter(isInviteAvailable)
48
48
+
const used = res.data.codes
49
49
+
.filter(code => !isInviteAvailable(code))
50
50
+
.sort((a, b) => {
51
51
+
return (
52
52
+
new Date(b.uses[0].usedAt).getTime() -
53
53
+
new Date(a.uses[0].usedAt).getTime()
54
54
+
)
55
55
+
})
56
56
+
57
57
+
return {
58
58
+
disabled: false,
59
59
+
all: [...available, ...used],
60
60
+
available,
61
61
+
used,
62
62
+
}
63
63
+
},
64
64
+
})
65
65
+
}
+1
src/view/com/composer/Composer.tsx
···
32
32
runOnUI,
33
33
scrollTo,
34
34
useAnimatedRef,
35
35
+
useAnimatedScrollHandler,
35
36
useAnimatedStyle,
36
37
useDerivedValue,
37
38
useSharedValue,
-364
src/view/com/util/forms/NativeDropdown.tsx
···
1
1
-
import React from 'react'
2
2
-
import {
3
3
-
Platform,
4
4
-
Pressable,
5
5
-
StyleSheet,
6
6
-
type TextStyle,
7
7
-
View,
8
8
-
type ViewStyle,
9
9
-
} from 'react-native'
10
10
-
import {type IconProp} from '@fortawesome/fontawesome-svg-core'
11
11
-
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
12
12
-
import * as DropdownMenu from 'zeego/dropdown-menu'
13
13
-
import {type MenuItemCommonProps} from 'zeego/lib/typescript/menu'
14
14
-
15
15
-
import {isIOS} from '#/platform/detection'
16
16
-
import {useTheme} from '#/alf'
17
17
-
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
18
18
-
import {Portal} from '#/components/Portal'
19
19
-
20
20
-
// Custom Dropdown Menu Components
21
21
-
// ==
22
22
-
/**
23
23
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
24
24
-
*/
25
25
-
export const DropdownMenuRoot = DropdownMenu.Root
26
26
-
// export const DropdownMenuTrigger = DropdownMenu.Trigger
27
27
-
/**
28
28
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
29
29
-
*/
30
30
-
export const DropdownMenuContent = DropdownMenu.Content
31
31
-
32
32
-
type TriggerProps = Omit<
33
33
-
React.ComponentProps<(typeof DropdownMenu)['Trigger']>,
34
34
-
'children'
35
35
-
> &
36
36
-
React.PropsWithChildren<{
37
37
-
testID?: string
38
38
-
accessibilityLabel?: string
39
39
-
accessibilityHint?: string
40
40
-
}>
41
41
-
/**
42
42
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
43
43
-
*/
44
44
-
export const DropdownMenuTrigger = DropdownMenu.create(
45
45
-
(props: TriggerProps) => {
46
46
-
const theme = useTheme()
47
47
-
48
48
-
const defaultCtrlColor = theme.palette.contrast_500
49
49
-
50
50
-
return (
51
51
-
// This Pressable doesn't actually do anything other than
52
52
-
// provide the "pressed state" visual feedback.
53
53
-
<Pressable
54
54
-
testID={props.testID}
55
55
-
accessibilityRole="button"
56
56
-
accessibilityLabel={props.accessibilityLabel}
57
57
-
accessibilityHint={props.accessibilityHint}
58
58
-
style={({pressed}) => [{opacity: pressed ? 0.8 : 1}]}>
59
59
-
<DropdownMenu.Trigger action="press">
60
60
-
<View>
61
61
-
{props.children ? (
62
62
-
props.children
63
63
-
) : (
64
64
-
<FontAwesomeIcon
65
65
-
icon="ellipsis"
66
66
-
size={20}
67
67
-
color={defaultCtrlColor}
68
68
-
/>
69
69
-
)}
70
70
-
</View>
71
71
-
</DropdownMenu.Trigger>
72
72
-
</Pressable>
73
73
-
)
74
74
-
},
75
75
-
'Trigger',
76
76
-
)
77
77
-
78
78
-
type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
79
79
-
/**
80
80
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
81
81
-
*/
82
82
-
export const DropdownMenuItem = DropdownMenu.create(
83
83
-
(props: ItemProps & {testID?: string}) => {
84
84
-
const theme = useTheme()
85
85
-
const colorMode = useColorModeTheme()
86
86
-
const [focused, setFocused] = React.useState(false)
87
87
-
const backgroundColor =
88
88
-
colorMode === 'light' ? theme.palette.black : theme.palette.white
89
89
-
90
90
-
return (
91
91
-
<DropdownMenu.Item
92
92
-
{...props}
93
93
-
style={[styles.item, focused && {backgroundColor: backgroundColor}]}
94
94
-
onFocus={() => {
95
95
-
setFocused(true)
96
96
-
props.onFocus && props.onFocus()
97
97
-
}}
98
98
-
onBlur={() => {
99
99
-
setFocused(false)
100
100
-
props.onBlur && props.onBlur()
101
101
-
}}
102
102
-
/>
103
103
-
)
104
104
-
},
105
105
-
'Item',
106
106
-
)
107
107
-
108
108
-
type TitleProps = React.ComponentProps<(typeof DropdownMenu)['ItemTitle']>
109
109
-
/**
110
110
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
111
111
-
*/
112
112
-
export const DropdownMenuItemTitle = DropdownMenu.create(
113
113
-
(props: TitleProps) => {
114
114
-
const theme = useTheme()
115
115
-
const colorMode = useColorModeTheme()
116
116
-
117
117
-
return (
118
118
-
<DropdownMenu.ItemTitle
119
119
-
{...props}
120
120
-
style={[
121
121
-
props.style,
122
122
-
{
123
123
-
color:
124
124
-
colorMode === 'light' ? theme.palette.black : theme.palette.white,
125
125
-
},
126
126
-
styles.itemTitle,
127
127
-
]}
128
128
-
/>
129
129
-
)
130
130
-
},
131
131
-
'ItemTitle',
132
132
-
)
133
133
-
134
134
-
type IconProps = React.ComponentProps<(typeof DropdownMenu)['ItemIcon']>
135
135
-
/**
136
136
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
137
137
-
*/
138
138
-
export const DropdownMenuItemIcon = DropdownMenu.create((props: IconProps) => {
139
139
-
return <DropdownMenu.ItemIcon {...props} />
140
140
-
}, 'ItemIcon')
141
141
-
142
142
-
type SeparatorProps = React.ComponentProps<(typeof DropdownMenu)['Separator']>
143
143
-
/**
144
144
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
145
145
-
*/
146
146
-
export const DropdownMenuSeparator = DropdownMenu.create(
147
147
-
(props: SeparatorProps) => {
148
148
-
const theme = useTheme()
149
149
-
const colorMode = useColorModeTheme()
150
150
-
const {borderColor: separatorColor} =
151
151
-
colorMode === 'dark'
152
152
-
? {
153
153
-
borderColor: theme.palette.contrast_200,
154
154
-
}
155
155
-
: {
156
156
-
borderColor: theme.palette.contrast_100,
157
157
-
}
158
158
-
return (
159
159
-
<DropdownMenu.Separator
160
160
-
{...props}
161
161
-
style={[
162
162
-
props.style,
163
163
-
styles.separator,
164
164
-
{backgroundColor: separatorColor},
165
165
-
]}
166
166
-
/>
167
167
-
)
168
168
-
},
169
169
-
'Separator',
170
170
-
)
171
171
-
172
172
-
// Types for Dropdown Menu and Items
173
173
-
export type DropdownItem = {
174
174
-
label: string | 'separator'
175
175
-
onPress?: () => void
176
176
-
testID?: string
177
177
-
icon?: {
178
178
-
ios: MenuItemCommonProps['ios']
179
179
-
android: string
180
180
-
web: IconProp
181
181
-
}
182
182
-
}
183
183
-
type Props = {
184
184
-
items: DropdownItem[]
185
185
-
testID?: string
186
186
-
accessibilityLabel?: string
187
187
-
accessibilityHint?: string
188
188
-
triggerStyle?: ViewStyle
189
189
-
}
190
190
-
191
191
-
/**
192
192
-
* The `NativeDropdown` function uses native iOS and Android dropdown menus.
193
193
-
* It also creates a animated custom dropdown for web that uses
194
194
-
* Radix UI primitives under the hood
195
195
-
* @prop {DropdownItem[]} items - An array of dropdown items
196
196
-
* @prop {React.ReactNode} children - A custom dropdown trigger
197
197
-
*
198
198
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
199
199
-
*/
200
200
-
export function NativeDropdown({
201
201
-
items,
202
202
-
children,
203
203
-
testID,
204
204
-
accessibilityLabel,
205
205
-
accessibilityHint,
206
206
-
}: React.PropsWithChildren<Props>) {
207
207
-
const theme = useTheme()
208
208
-
const colorMode = useColorModeTheme()
209
209
-
const [isOpen, setIsOpen] = React.useState(false)
210
210
-
const dropDownBackgroundColor = {
211
211
-
backgroundColor: theme.palette.contrast_25,
212
212
-
}
213
213
-
214
214
-
const textStyle: TextStyle = {
215
215
-
color: colorMode === 'light' ? theme.palette.black : theme.palette.white,
216
216
-
}
217
217
-
218
218
-
return (
219
219
-
<>
220
220
-
{isIOS && isOpen && (
221
221
-
<Portal>
222
222
-
<Backdrop />
223
223
-
</Portal>
224
224
-
)}
225
225
-
<DropdownMenuRoot onOpenWillChange={setIsOpen}>
226
226
-
<DropdownMenuTrigger
227
227
-
action="press"
228
228
-
testID={testID}
229
229
-
accessibilityLabel={accessibilityLabel}
230
230
-
accessibilityHint={accessibilityHint}>
231
231
-
{children}
232
232
-
</DropdownMenuTrigger>
233
233
-
{/* @ts-ignore inheriting props from Radix, which is only for web */}
234
234
-
<DropdownMenuContent
235
235
-
style={[styles.content, dropDownBackgroundColor]}
236
236
-
loop>
237
237
-
{items.map((item, index) => {
238
238
-
if (item.label === 'separator') {
239
239
-
return (
240
240
-
<DropdownMenuSeparator
241
241
-
key={getKey(item.label, index, item.testID)}
242
242
-
/>
243
243
-
)
244
244
-
}
245
245
-
if (index > 1 && items[index - 1].label === 'separator') {
246
246
-
return (
247
247
-
<DropdownMenu.Group
248
248
-
key={getKey(item.label, index, item.testID)}>
249
249
-
<DropdownMenuItem
250
250
-
key={getKey(item.label, index, item.testID)}
251
251
-
onSelect={item.onPress}>
252
252
-
<DropdownMenuItemTitle>{item.label}</DropdownMenuItemTitle>
253
253
-
{item.icon && (
254
254
-
<DropdownMenuItemIcon
255
255
-
ios={item.icon.ios}
256
256
-
// androidIconName={item.icon.android} TODO: Add custom android icon support, because these ones are based on https://developer.android.com/reference/android/R.drawable.html and they are ugly
257
257
-
>
258
258
-
<FontAwesomeIcon
259
259
-
icon={item.icon.web}
260
260
-
size={20}
261
261
-
style={[textStyle]}
262
262
-
/>
263
263
-
</DropdownMenuItemIcon>
264
264
-
)}
265
265
-
</DropdownMenuItem>
266
266
-
</DropdownMenu.Group>
267
267
-
)
268
268
-
}
269
269
-
return (
270
270
-
<DropdownMenuItem
271
271
-
key={getKey(item.label, index, item.testID)}
272
272
-
onSelect={item.onPress}>
273
273
-
<DropdownMenuItemTitle>{item.label}</DropdownMenuItemTitle>
274
274
-
{item.icon && (
275
275
-
<DropdownMenuItemIcon
276
276
-
ios={item.icon.ios}
277
277
-
// androidIconName={item.icon.android}
278
278
-
>
279
279
-
<FontAwesomeIcon
280
280
-
icon={item.icon.web}
281
281
-
size={20}
282
282
-
style={[textStyle]}
283
283
-
/>
284
284
-
</DropdownMenuItemIcon>
285
285
-
)}
286
286
-
</DropdownMenuItem>
287
287
-
)
288
288
-
})}
289
289
-
</DropdownMenuContent>
290
290
-
</DropdownMenuRoot>
291
291
-
</>
292
292
-
)
293
293
-
}
294
294
-
295
295
-
function Backdrop() {
296
296
-
// Not visible but it eats the click outside.
297
297
-
// Only necessary for iOS.
298
298
-
return (
299
299
-
<Pressable
300
300
-
accessibilityRole="button"
301
301
-
accessibilityLabel="Dialog backdrop"
302
302
-
accessibilityHint="Press the backdrop to close the dialog"
303
303
-
style={{
304
304
-
top: 0,
305
305
-
left: 0,
306
306
-
right: 0,
307
307
-
bottom: 0,
308
308
-
position: 'absolute',
309
309
-
}}
310
310
-
onPress={() => {
311
311
-
/* noop */
312
312
-
}}
313
313
-
/>
314
314
-
)
315
315
-
}
316
316
-
317
317
-
const getKey = (label: string, index: number, id?: string) => {
318
318
-
if (id) {
319
319
-
return id
320
320
-
}
321
321
-
return `${label}_${index}`
322
322
-
}
323
323
-
324
324
-
const styles = StyleSheet.create({
325
325
-
separator: {
326
326
-
height: 1,
327
327
-
marginVertical: 4,
328
328
-
},
329
329
-
content: {
330
330
-
backgroundColor: '#f0f0f0',
331
331
-
borderRadius: 8,
332
332
-
paddingVertical: 4,
333
333
-
paddingHorizontal: 4,
334
334
-
marginTop: 6,
335
335
-
...Platform.select({
336
336
-
web: {
337
337
-
animationDuration: '400ms',
338
338
-
animationTimingFunction: 'cubic-bezier(0.16, 1, 0.3, 1)',
339
339
-
willChange: 'transform, opacity',
340
340
-
animationKeyframes: {
341
341
-
'0%': {opacity: 0, transform: [{scale: 0.5}]},
342
342
-
'100%': {opacity: 1, transform: [{scale: 1}]},
343
343
-
},
344
344
-
boxShadow:
345
345
-
'0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)',
346
346
-
transformOrigin: 'var(--radix-dropdown-menu-content-transform-origin)',
347
347
-
},
348
348
-
}),
349
349
-
},
350
350
-
item: {
351
351
-
flexDirection: 'row',
352
352
-
justifyContent: 'space-between',
353
353
-
alignItems: 'center',
354
354
-
columnGap: 20,
355
355
-
// @ts-ignore -web
356
356
-
cursor: 'pointer',
357
357
-
paddingVertical: 8,
358
358
-
paddingHorizontal: 12,
359
359
-
borderRadius: 8,
360
360
-
},
361
361
-
itemTitle: {
362
362
-
fontSize: 18,
363
363
-
},
364
364
-
})
-314
src/view/com/util/forms/NativeDropdown.web.tsx
···
1
1
-
import React from 'react'
2
2
-
import {
3
3
-
Pressable,
4
4
-
StyleSheet,
5
5
-
Text,
6
6
-
type TextStyle,
7
7
-
type View,
8
8
-
type ViewStyle,
9
9
-
} from 'react-native'
10
10
-
import {type IconProp} from '@fortawesome/fontawesome-svg-core'
11
11
-
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
12
12
-
import {DropdownMenu} from 'radix-ui'
13
13
-
import {type MenuItemCommonProps} from 'zeego/lib/typescript/menu'
14
14
-
15
15
-
import {HITSLOP_10} from '#/lib/constants'
16
16
-
import {useTheme} from '#/alf'
17
17
-
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
18
18
-
19
19
-
// Custom Dropdown Menu Components
20
20
-
// ==
21
21
-
export const DropdownMenuRoot = DropdownMenu.Root
22
22
-
export const DropdownMenuContent = DropdownMenu.Content
23
23
-
24
24
-
type ItemProps = React.ComponentProps<(typeof DropdownMenu)['Item']>
25
25
-
export const DropdownMenuItem = (props: ItemProps & {testID?: string}) => {
26
26
-
const [focused, setFocused] = React.useState(false)
27
27
-
const theme = useTheme()
28
28
-
const colorMode = useColorModeTheme()
29
29
-
const backgroundColor =
30
30
-
colorMode === 'light' ? theme.palette.black : theme.palette.white
31
31
-
32
32
-
return (
33
33
-
<DropdownMenu.Item
34
34
-
className="nativeDropdown-item"
35
35
-
{...props}
36
36
-
style={StyleSheet.flatten([
37
37
-
styles.item,
38
38
-
focused && {backgroundColor: backgroundColor},
39
39
-
])}
40
40
-
onFocus={() => {
41
41
-
setFocused(true)
42
42
-
}}
43
43
-
onBlur={() => {
44
44
-
setFocused(false)
45
45
-
}}
46
46
-
/>
47
47
-
)
48
48
-
}
49
49
-
50
50
-
// Types for Dropdown Menu and Items
51
51
-
export type DropdownItem = {
52
52
-
label: string | 'separator'
53
53
-
onPress?: () => void
54
54
-
testID?: string
55
55
-
icon?: {
56
56
-
ios: MenuItemCommonProps['ios']
57
57
-
android: string
58
58
-
web: IconProp
59
59
-
}
60
60
-
}
61
61
-
type Props = {
62
62
-
items: DropdownItem[]
63
63
-
testID?: string
64
64
-
accessibilityLabel?: string
65
65
-
accessibilityHint?: string
66
66
-
triggerStyle?: ViewStyle
67
67
-
}
68
68
-
69
69
-
/**
70
70
-
* @deprecated use Menu from `#/components/Menu.tsx` instead
71
71
-
*/
72
72
-
export function NativeDropdown({
73
73
-
items,
74
74
-
children,
75
75
-
testID,
76
76
-
accessibilityLabel,
77
77
-
accessibilityHint,
78
78
-
triggerStyle,
79
79
-
}: React.PropsWithChildren<Props>) {
80
80
-
const [open, setOpen] = React.useState(false)
81
81
-
const buttonRef = React.useRef<HTMLButtonElement>(null)
82
82
-
const menuRef = React.useRef<HTMLDivElement>(null)
83
83
-
84
84
-
React.useEffect(() => {
85
85
-
if (!open) {
86
86
-
return
87
87
-
}
88
88
-
89
89
-
function clickHandler(e: MouseEvent) {
90
90
-
const t = e.target
91
91
-
92
92
-
if (!open) return
93
93
-
if (!t) return
94
94
-
if (!buttonRef.current || !menuRef.current) return
95
95
-
96
96
-
if (
97
97
-
t !== buttonRef.current &&
98
98
-
!buttonRef.current.contains(t as Node) &&
99
99
-
t !== menuRef.current &&
100
100
-
!menuRef.current.contains(t as Node)
101
101
-
) {
102
102
-
// prevent clicking through to links beneath dropdown
103
103
-
// only applies to mobile web
104
104
-
e.preventDefault()
105
105
-
e.stopPropagation()
106
106
-
107
107
-
// close menu
108
108
-
setOpen(false)
109
109
-
}
110
110
-
}
111
111
-
112
112
-
function keydownHandler(e: KeyboardEvent) {
113
113
-
if (e.key === 'Escape' && open) {
114
114
-
setOpen(false)
115
115
-
}
116
116
-
}
117
117
-
118
118
-
document.addEventListener('click', clickHandler, true)
119
119
-
window.addEventListener('keydown', keydownHandler, true)
120
120
-
return () => {
121
121
-
document.removeEventListener('click', clickHandler, true)
122
122
-
window.removeEventListener('keydown', keydownHandler, true)
123
123
-
}
124
124
-
}, [open, setOpen])
125
125
-
126
126
-
return (
127
127
-
<DropdownMenuRoot open={open} onOpenChange={o => setOpen(o)}>
128
128
-
<DropdownMenu.Trigger asChild>
129
129
-
<Pressable
130
130
-
ref={buttonRef as unknown as React.Ref<View>}
131
131
-
testID={testID}
132
132
-
accessibilityRole="button"
133
133
-
accessibilityLabel={accessibilityLabel}
134
134
-
accessibilityHint={accessibilityHint}
135
135
-
onPointerDown={e => {
136
136
-
// Prevent false positive that interpret mobile scroll as a tap.
137
137
-
// This requires the custom onPress handler below to compensate.
138
138
-
// https://github.com/radix-ui/primitives/issues/1912
139
139
-
e.preventDefault()
140
140
-
}}
141
141
-
onPress={() => {
142
142
-
if (window.event instanceof KeyboardEvent) {
143
143
-
// The onPointerDown hack above is not relevant to this press, so don't do anything.
144
144
-
return
145
145
-
}
146
146
-
// Compensate for the disabled onPointerDown above by triggering it manually.
147
147
-
setOpen(o => !o)
148
148
-
}}
149
149
-
hitSlop={HITSLOP_10}
150
150
-
style={triggerStyle}>
151
151
-
{children}
152
152
-
</Pressable>
153
153
-
</DropdownMenu.Trigger>
154
154
-
155
155
-
<DropdownMenu.Portal>
156
156
-
<DropdownContent items={items} menuRef={menuRef} />
157
157
-
</DropdownMenu.Portal>
158
158
-
</DropdownMenuRoot>
159
159
-
)
160
160
-
}
161
161
-
162
162
-
function DropdownContent({
163
163
-
items,
164
164
-
menuRef,
165
165
-
}: {
166
166
-
items: DropdownItem[]
167
167
-
menuRef: React.RefObject<HTMLDivElement | null>
168
168
-
}) {
169
169
-
const theme = useTheme()
170
170
-
const colorMode = useColorModeTheme()
171
171
-
const dropDownBackgroundColor =
172
172
-
colorMode === 'dark'
173
173
-
? {
174
174
-
backgroundColor: theme.palette.contrast_25,
175
175
-
}
176
176
-
: {
177
177
-
backgroundColor:
178
178
-
colorMode === 'light' ? theme.palette.white : theme.palette.black,
179
179
-
}
180
180
-
const {borderColor: separatorColor} =
181
181
-
colorMode === 'dark'
182
182
-
? {
183
183
-
borderColor: theme.palette.contrast_200,
184
184
-
}
185
185
-
: {
186
186
-
borderColor: theme.palette.contrast_100,
187
187
-
}
188
188
-
189
189
-
const textStyle: TextStyle = {
190
190
-
color: colorMode === 'light' ? theme.palette.black : theme.palette.white,
191
191
-
}
192
192
-
193
193
-
return (
194
194
-
<DropdownMenu.Content
195
195
-
ref={menuRef}
196
196
-
style={
197
197
-
StyleSheet.flatten([
198
198
-
styles.content,
199
199
-
dropDownBackgroundColor,
200
200
-
]) as React.CSSProperties
201
201
-
}
202
202
-
loop>
203
203
-
{items.map((item, index) => {
204
204
-
if (item.label === 'separator') {
205
205
-
return (
206
206
-
<DropdownMenu.Separator
207
207
-
key={getKey(item.label, index, item.testID)}
208
208
-
style={
209
209
-
StyleSheet.flatten([
210
210
-
styles.separator,
211
211
-
{backgroundColor: separatorColor},
212
212
-
]) as React.CSSProperties
213
213
-
}
214
214
-
/>
215
215
-
)
216
216
-
}
217
217
-
if (index > 1 && items[index - 1].label === 'separator') {
218
218
-
return (
219
219
-
<DropdownMenu.Group key={getKey(item.label, index, item.testID)}>
220
220
-
<DropdownMenuItem
221
221
-
key={getKey(item.label, index, item.testID)}
222
222
-
onSelect={item.onPress}>
223
223
-
<Text selectable={false} style={[textStyle, styles.itemTitle]}>
224
224
-
{item.label}
225
225
-
</Text>
226
226
-
{item.icon && (
227
227
-
<FontAwesomeIcon
228
228
-
icon={item.icon.web}
229
229
-
size={20}
230
230
-
color={
231
231
-
colorMode === 'light'
232
232
-
? theme.palette.white
233
233
-
: theme.palette.black
234
234
-
}
235
235
-
/>
236
236
-
)}
237
237
-
</DropdownMenuItem>
238
238
-
</DropdownMenu.Group>
239
239
-
)
240
240
-
}
241
241
-
return (
242
242
-
<DropdownMenuItem
243
243
-
key={getKey(item.label, index, item.testID)}
244
244
-
onSelect={item.onPress}>
245
245
-
<Text selectable={false} style={[textStyle, styles.itemTitle]}>
246
246
-
{item.label}
247
247
-
</Text>
248
248
-
{item.icon && (
249
249
-
<FontAwesomeIcon
250
250
-
icon={item.icon.web}
251
251
-
size={20}
252
252
-
color={
253
253
-
colorMode === 'light'
254
254
-
? theme.palette.white
255
255
-
: theme.palette.black
256
256
-
}
257
257
-
/>
258
258
-
)}
259
259
-
</DropdownMenuItem>
260
260
-
)
261
261
-
})}
262
262
-
</DropdownMenu.Content>
263
263
-
)
264
264
-
}
265
265
-
266
266
-
const getKey = (label: string, index: number, id?: string) => {
267
267
-
if (id) {
268
268
-
return id
269
269
-
}
270
270
-
return `${label}_${index}`
271
271
-
}
272
272
-
273
273
-
const styles = StyleSheet.create({
274
274
-
separator: {
275
275
-
height: 1,
276
276
-
marginTop: 4,
277
277
-
marginBottom: 4,
278
278
-
},
279
279
-
content: {
280
280
-
backgroundColor: '#f0f0f0',
281
281
-
borderRadius: 8,
282
282
-
paddingTop: 4,
283
283
-
paddingBottom: 4,
284
284
-
paddingLeft: 4,
285
285
-
paddingRight: 4,
286
286
-
marginTop: 6,
287
287
-
288
288
-
// @ts-ignore web only -prf
289
289
-
boxShadow: 'rgba(0, 0, 0, 0.3) 0px 5px 20px',
290
290
-
},
291
291
-
item: {
292
292
-
display: 'flex',
293
293
-
flexDirection: 'row',
294
294
-
justifyContent: 'space-between',
295
295
-
alignItems: 'center',
296
296
-
columnGap: 20,
297
297
-
cursor: 'pointer',
298
298
-
paddingTop: 8,
299
299
-
paddingBottom: 8,
300
300
-
paddingLeft: 12,
301
301
-
paddingRight: 12,
302
302
-
borderRadius: 8,
303
303
-
fontFamily:
304
304
-
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Liberation Sans", Helvetica, Arial, sans-serif',
305
305
-
// @ts-expect-error web only
306
306
-
outline: 0,
307
307
-
border: 0,
308
308
-
},
309
309
-
itemTitle: {
310
310
-
fontSize: 16,
311
311
-
fontWeight: '600',
312
312
-
paddingRight: 10,
313
313
-
},
314
314
-
})