Bluesky app fork with some witchin' additions 馃挮
at main 280 lines 9.0 kB view raw
1import {useEffect, useMemo, useState} from 'react' 2import {useWindowDimensions, View} from 'react-native' 3import Animated, { 4 FadeIn, 5 FadeOut, 6 LayoutAnimationConfig, 7 LinearTransition, 8 SlideInRight, 9 SlideOutLeft, 10} from 'react-native-reanimated' 11import {type ComAtprotoServerCreateAppPassword} from '@atproto/api' 12import {msg} from '@lingui/core/macro' 13import {useLingui} from '@lingui/react' 14import {Trans} from '@lingui/react/macro' 15import {useMutation} from '@tanstack/react-query' 16 17import {useAppPasswordCreateMutation} from '#/state/queries/app-passwords' 18import {atoms as a, native, useTheme} from '#/alf' 19import {Admonition} from '#/components/Admonition' 20import {Button, ButtonIcon, ButtonText} from '#/components/Button' 21import * as Dialog from '#/components/Dialog' 22import * as TextInput from '#/components/forms/TextField' 23import * as Toggle from '#/components/forms/Toggle' 24import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron' 25import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' 26import {Text} from '#/components/Typography' 27import {IS_WEB} from '#/env' 28import {CopyButton} from './CopyButton' 29 30export function AddAppPasswordDialog({ 31 control, 32 passwords, 33}: { 34 control: Dialog.DialogControlProps 35 passwords: string[] 36}) { 37 const {height} = useWindowDimensions() 38 return ( 39 <Dialog.Outer control={control} nativeOptions={{minHeight: height}}> 40 <Dialog.Handle /> 41 <CreateDialogInner passwords={passwords} /> 42 </Dialog.Outer> 43 ) 44} 45 46function CreateDialogInner({passwords}: {passwords: string[]}) { 47 const control = Dialog.useDialogContext() 48 const t = useTheme() 49 const {_} = useLingui() 50 const autogeneratedName = useRandomName() 51 const [name, setName] = useState('') 52 const [privileged, setPrivileged] = useState(false) 53 const { 54 mutateAsync: actuallyCreateAppPassword, 55 error: apiError, 56 data, 57 } = useAppPasswordCreateMutation() 58 59 const regexFailError = useMemo( 60 () => 61 new DisplayableError( 62 _( 63 msg`App password names can only contain letters, numbers, spaces, dashes, and underscores`, 64 ), 65 ), 66 [_], 67 ) 68 69 const { 70 mutate: createAppPassword, 71 error: validationError, 72 isPending, 73 } = useMutation< 74 ComAtprotoServerCreateAppPassword.AppPassword, 75 Error | DisplayableError 76 >({ 77 mutationFn: async () => { 78 const chosenName = name.trim() || autogeneratedName 79 if (chosenName.length < 4) { 80 throw new DisplayableError( 81 _(msg`App password names must be at least 4 characters long`), 82 ) 83 } 84 if (passwords.find(p => p === chosenName)) { 85 throw new DisplayableError(_(msg`App password name must be unique`)) 86 } 87 return await actuallyCreateAppPassword({name: chosenName, privileged}) 88 }, 89 }) 90 91 const [hasBeenCopied, setHasBeenCopied] = useState(false) 92 useEffect(() => { 93 if (hasBeenCopied) { 94 const timeout = setTimeout(() => setHasBeenCopied(false), 100) 95 return () => clearTimeout(timeout) 96 } 97 }, [hasBeenCopied]) 98 99 const error = 100 validationError || (!name.match(/^[a-zA-Z0-9-_ ]*$/) && regexFailError) 101 102 return ( 103 <Dialog.ScrollableInner label={_(msg`Add app password`)}> 104 <View style={[native(a.pt_md)]}> 105 <LayoutAnimationConfig skipEntering skipExiting> 106 {!data ? ( 107 <Animated.View 108 style={[a.gap_lg]} 109 exiting={native(SlideOutLeft)} 110 key={0}> 111 <Text style={[a.text_2xl, a.font_semi_bold]}> 112 <Trans>Add App Password</Trans> 113 </Text> 114 <Text style={[a.text_md, a.leading_snug]}> 115 <Trans> 116 Please enter a unique name for this app password or use our 117 randomly generated one. 118 </Trans> 119 </Text> 120 <View> 121 <TextInput.Root isInvalid={!!error}> 122 <Dialog.Input 123 label={_(msg`App Password`)} 124 placeholder={autogeneratedName} 125 onChangeText={setName} 126 returnKeyType="done" 127 onSubmitEditing={() => createAppPassword()} 128 blurOnSubmit 129 autoCorrect={false} 130 autoComplete="off" 131 autoCapitalize="none" 132 autoFocus 133 /> 134 </TextInput.Root> 135 </View> 136 {error instanceof DisplayableError && ( 137 <Animated.View entering={FadeIn} exiting={FadeOut}> 138 <Admonition type="error">{error.message}</Admonition> 139 </Animated.View> 140 )} 141 <Animated.View 142 style={[a.gap_lg]} 143 layout={native(LinearTransition)}> 144 <Toggle.Item 145 name="privileged" 146 type="checkbox" 147 label={_(msg`Allow access to your direct messages`)} 148 value={privileged} 149 onChange={setPrivileged} 150 style={[a.flex_1]}> 151 <Toggle.Checkbox /> 152 <Toggle.LabelText 153 style={[a.font_normal, a.text_md, a.leading_snug]}> 154 <Trans>Allow access to your direct messages</Trans> 155 </Toggle.LabelText> 156 </Toggle.Item> 157 <Button 158 label={_(msg`Next`)} 159 size="large" 160 variant="solid" 161 color="primary" 162 style={[a.flex_1]} 163 onPress={() => createAppPassword()} 164 disabled={isPending}> 165 <ButtonText> 166 <Trans>Next</Trans> 167 </ButtonText> 168 <ButtonIcon icon={ChevronRight} /> 169 </Button> 170 {!!apiError || 171 (error && !(error instanceof DisplayableError) && ( 172 <Animated.View entering={FadeIn} exiting={FadeOut}> 173 <Admonition type="error"> 174 <Trans> 175 Failed to create app password. Please try again. 176 </Trans> 177 </Admonition> 178 </Animated.View> 179 ))} 180 </Animated.View> 181 </Animated.View> 182 ) : ( 183 <Animated.View 184 style={[a.gap_lg]} 185 entering={IS_WEB ? FadeIn.delay(200) : SlideInRight} 186 key={1}> 187 <Text style={[a.text_2xl, a.font_semi_bold]}> 188 <Trans>Here is your app password!</Trans> 189 </Text> 190 <Text style={[a.text_md, a.leading_snug]}> 191 <Trans> 192 Use this to sign in to the other app along with your handle. 193 </Trans> 194 </Text> 195 <CopyButton 196 value={data.password} 197 label={_(msg`Copy App Password`)} 198 size="large" 199 color="secondary"> 200 <ButtonText>{data.password}</ButtonText> 201 <ButtonIcon icon={CopyIcon} /> 202 </CopyButton> 203 <Text 204 style={[ 205 a.text_md, 206 a.leading_snug, 207 t.atoms.text_contrast_medium, 208 ]}> 209 <Trans> 210 For security reasons, you won't be able to view this again. If 211 you lose this app password, you'll need to generate a new one. 212 </Trans> 213 </Text> 214 <Button 215 label={_(msg`Done`)} 216 size="large" 217 variant="outline" 218 color="primary" 219 style={[a.flex_1]} 220 onPress={() => control.close()}> 221 <ButtonText> 222 <Trans>Done</Trans> 223 </ButtonText> 224 </Button> 225 </Animated.View> 226 )} 227 </LayoutAnimationConfig> 228 </View> 229 <Dialog.Close /> 230 </Dialog.ScrollableInner> 231 ) 232} 233 234class DisplayableError extends Error { 235 constructor(message: string) { 236 super(message) 237 this.name = 'DisplayableError' 238 } 239} 240 241function useRandomName() { 242 return useState( 243 () => shadesOfBlue[Math.floor(Math.random() * shadesOfBlue.length)], 244 )[0] 245} 246 247const shadesOfBlue: string[] = [ 248 'AliceBlue', 249 'Aqua', 250 'Aquamarine', 251 'Azure', 252 'BabyBlue', 253 'Blue', 254 'BlueViolet', 255 'CadetBlue', 256 'CornflowerBlue', 257 'Cyan', 258 'DarkBlue', 259 'DarkCyan', 260 'DarkSlateBlue', 261 'DeepSkyBlue', 262 'DodgerBlue', 263 'ElectricBlue', 264 'LightBlue', 265 'LightCyan', 266 'LightSkyBlue', 267 'LightSteelBlue', 268 'MediumAquaMarine', 269 'MediumBlue', 270 'MediumSlateBlue', 271 'MidnightBlue', 272 'Navy', 273 'PowderBlue', 274 'RoyalBlue', 275 'SkyBlue', 276 'SlateBlue', 277 'SteelBlue', 278 'Teal', 279 'Turquoise', 280]