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} 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]