An ATproto social media client -- with an independent Appview.

fix: i can be trusted with force push to main perms

serenity 47e67d9b 1de143de

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