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