forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback, useEffect, useMemo, useRef, useState} from 'react'
2import {KeyboardAvoidingView} from 'react-native'
3import Animated, {FadeIn, LayoutAnimationConfig} from 'react-native-reanimated'
4import {type Did} from '@atproto/api'
5import {msg} from '@lingui/core/macro'
6import {useLingui} from '@lingui/react'
7import debounce from 'lodash.debounce'
8
9import {DEFAULT_SERVICE} from '#/lib/constants'
10import {logger} from '#/logger'
11import {resolvePdsServiceUrl} from '#/state/queries/resolve-identity'
12import {useServiceQuery} from '#/state/queries/service'
13import {type SessionAccount, useAgent, useSession} from '#/state/session'
14import {useLoggedOutView} from '#/state/shell/logged-out'
15import {LoggedOutLayout} from '#/view/com/util/layouts/LoggedOutLayout'
16import {ForgotPasswordForm} from '#/screens/Login/ForgotPasswordForm'
17import {LoginForm} from '#/screens/Login/LoginForm'
18import {PasswordUpdatedForm} from '#/screens/Login/PasswordUpdatedForm'
19import {SetNewPasswordForm} from '#/screens/Login/SetNewPasswordForm'
20import {atoms as a, native} from '#/alf'
21import {ScreenTransition} from '#/components/ScreenTransition'
22import {useAnalytics} from '#/analytics'
23import {IS_WEB} from '#/env'
24import {ChooseAccountForm} from './ChooseAccountForm'
25import * as AuthLayout from './components/AuthLayout'
26import {AuthLayoutNavigationContext} from './components/AuthLayout/context'
27
28enum Forms {
29 Login,
30 ChooseAccount,
31 ForgotPassword,
32 SetNewPassword,
33 PasswordUpdated,
34}
35
36const OrderedForms = [
37 Forms.ChooseAccount,
38 Forms.Login,
39 Forms.ForgotPassword,
40 Forms.SetNewPassword,
41 Forms.PasswordUpdated,
42] as const
43
44export const Login = ({onPressBack}: {onPressBack: () => void}) => {
45 const {_} = useLingui()
46 const failedAttemptCountRef = useRef(0)
47 const startTimeRef = useRef(Date.now())
48
49 const agent = useAgent()
50 const {accounts} = useSession()
51 const {requestedAccountSwitchTo} = useLoggedOutView()
52 const requestedAccount = accounts.find(
53 acc => acc.did === requestedAccountSwitchTo,
54 )
55
56 const [isResolvingService, setIsResolvingService] = useState(false)
57 const [error, setError] = useState<string>('')
58 const [serviceUrl, setServiceUrl] = useState<string | undefined>(
59 requestedAccount?.service,
60 )
61 const [initialHandle, setInitialHandle] = useState(
62 requestedAccount?.handle || '',
63 )
64 const [currentForm, setCurrentForm] = useState<Forms>(
65 requestedAccount
66 ? Forms.Login
67 : accounts.length
68 ? Forms.ChooseAccount
69 : Forms.Login,
70 )
71 const [screenTransitionDirection, setScreenTransitionDirection] = useState<
72 'Forward' | 'Backward'
73 >('Forward')
74
75 const ax = useAnalytics()
76 const {
77 data: serviceDescription,
78 error: serviceError,
79 refetch: refetchService,
80 } = useServiceQuery(serviceUrl ?? '')
81
82 const onSelectAccount = (account?: SessionAccount) => {
83 if (account?.service) {
84 setServiceUrl(account.service)
85 }
86 setInitialHandle(account?.handle || '')
87 gotoForm(Forms.Login)
88 }
89
90 const gotoForm = (form: Forms) => {
91 setError('')
92 const index = OrderedForms.indexOf(currentForm)
93 const nextIndex = OrderedForms.indexOf(form)
94 setScreenTransitionDirection(index < nextIndex ? 'Forward' : 'Backward')
95 setCurrentForm(form)
96 }
97
98 useEffect(() => {
99 if (serviceError) {
100 setError(
101 _(
102 msg`Unable to contact your service. Please check your Internet connection.`,
103 ),
104 )
105 logger.warn(`Failed to fetch service description for ${serviceUrl}`, {
106 error: String(serviceError),
107 })
108 ax.metric('signin:hostingProviderFailedResolution', {})
109 } else {
110 setError('')
111 }
112 }, [serviceError, serviceUrl, _])
113
114 const resolveIdentity = useCallback(
115 async (identifier: string) => {
116 setIsResolvingService(true)
117
118 try {
119 const getDid = async () => {
120 if (identifier.startsWith('did:')) return identifier
121 else
122 return (
123 await agent.resolveHandle({
124 handle: identifier,
125 })
126 ).data.did
127 }
128
129 const did = (await getDid()) as Did
130 const pdsUrl = await resolvePdsServiceUrl(did)
131
132 if (!pdsUrl) {
133 throw new Error(`No PDS service found in DID document for ${did}`)
134 }
135
136 if (pdsUrl.endsWith('.bsky.network')) {
137 setServiceUrl('https://bsky.social')
138 } else {
139 setServiceUrl(pdsUrl)
140 }
141 } catch (err) {
142 logger.error(
143 `Service auto-resolution failed: ${err instanceof Error ? err.message : String(err)}`,
144 )
145 } finally {
146 setIsResolvingService(false)
147 }
148 },
149 [agent],
150 )
151
152 const debouncedResolveService = useMemo(
153 () => debounce(resolveIdentity, 400),
154 [resolveIdentity],
155 )
156
157 const onPressForgotPassword = () => {
158 gotoForm(Forms.ForgotPassword)
159 ax.metric('signin:forgotPasswordPressed', {})
160 }
161
162 const handlePressBack = () => {
163 onPressBack()
164 setScreenTransitionDirection('Backward')
165 ax.metric('signin:backPressed', {
166 failedAttemptsCount: failedAttemptCountRef.current,
167 })
168 }
169
170 const onAttemptSuccess = () => {
171 ax.metric('signin:success', {
172 isUsingCustomProvider: serviceUrl !== DEFAULT_SERVICE,
173 timeTakenSeconds: Math.round((Date.now() - startTimeRef.current) / 1000),
174 failedAttemptsCount: failedAttemptCountRef.current,
175 })
176 }
177
178 const onAttemptFailed = () => {
179 failedAttemptCountRef.current += 1
180 }
181
182 let content = null
183 let title = ''
184 let description = ''
185 let goBack = null
186
187 switch (currentForm) {
188 case Forms.Login:
189 title = _(msg`Sign in`)
190 description = IS_WEB
191 ? _(msg`Enter your handle to sign in`)
192 : _(msg`Enter your username and password`)
193 goBack = () =>
194 accounts.length ? gotoForm(Forms.ChooseAccount) : handlePressBack()
195 content = (
196 <LoginForm
197 error={error}
198 serviceUrl={serviceUrl}
199 serviceDescription={serviceDescription}
200 initialHandle={initialHandle}
201 setError={setError}
202 onAttemptFailed={onAttemptFailed}
203 onAttemptSuccess={onAttemptSuccess}
204 setServiceUrl={setServiceUrl}
205 onPressBack={goBack}
206 onPressForgotPassword={onPressForgotPassword}
207 onPressRetryConnect={refetchService}
208 debouncedResolveService={debouncedResolveService}
209 isResolvingService={isResolvingService}
210 />
211 )
212 break
213 case Forms.ChooseAccount:
214 title = _(msg`Sign in`)
215 description = _(msg`Select from an existing account`)
216 goBack = handlePressBack
217 content = (
218 <ChooseAccountForm
219 onSelectAccount={onSelectAccount}
220 onPressBack={goBack}
221 />
222 )
223 break
224 case Forms.ForgotPassword:
225 title = _(msg`Forgot Password`)
226 description = _(msg`Let's get your password reset!`)
227 goBack = () => gotoForm(Forms.Login)
228 content = (
229 <ForgotPasswordForm
230 error={error}
231 serviceUrl={serviceUrl ?? DEFAULT_SERVICE}
232 serviceDescription={serviceDescription}
233 setError={setError}
234 setServiceUrl={setServiceUrl}
235 onPressBack={goBack}
236 onEmailSent={() => gotoForm(Forms.SetNewPassword)}
237 />
238 )
239 break
240 case Forms.SetNewPassword:
241 title = _(msg`Forgot Password`)
242 description = _(msg`Let's get your password reset!`)
243 goBack = () => gotoForm(Forms.ForgotPassword)
244 content = (
245 <SetNewPasswordForm
246 error={error}
247 serviceUrl={serviceUrl ?? DEFAULT_SERVICE}
248 setError={setError}
249 onPressBack={goBack}
250 onPasswordSet={() => gotoForm(Forms.PasswordUpdated)}
251 />
252 )
253 break
254 case Forms.PasswordUpdated:
255 title = _(msg`Password updated`)
256 description = _(msg`You can now sign in with your new password.`)
257 content = (
258 <PasswordUpdatedForm onPressNext={() => gotoForm(Forms.Login)} />
259 )
260 break
261 }
262
263 const navigation = goBack ? {goBack} : null
264
265 return (
266 <AuthLayoutNavigationContext value={navigation}>
267 <Animated.View style={a.flex_1} entering={native(FadeIn.duration(90))}>
268 <KeyboardAvoidingView
269 testID="signIn"
270 behavior="padding"
271 style={a.flex_1}>
272 <AuthLayout.Header.Outer>
273 <AuthLayout.Header.BackButton />
274 <AuthLayout.Header.Content />
275 <AuthLayout.Header.Slot />
276 </AuthLayout.Header.Outer>
277 <LoggedOutLayout
278 leadin=""
279 title={title}
280 description={description}
281 scrollable>
282 <LayoutAnimationConfig skipEntering>
283 <ScreenTransition
284 key={currentForm}
285 direction={screenTransitionDirection}>
286 {content}
287 </ScreenTransition>
288 </LayoutAnimationConfig>
289 </LoggedOutLayout>
290 </KeyboardAvoidingView>
291 </Animated.View>
292 </AuthLayoutNavigationContext>
293 )
294}