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