personal fork of bluesky app bsky.kelinci.net
0

Configure Feed

Select the types of activity you want to include in your feed.

social-app-fork / src / components / dialogs / Signin.tsx
6.9 kB 232 lines
1import { type KeyboardEvent, useCallback, useState } from 'react'; 2 3import { isActorIdentifier } from '@atcute/lexicons/syntax'; 4 5import { errorMessage } from '#/lib/strings/errors'; 6 7import { login, type SessionAccount, switchAccount, useSession } from '#/state/session'; 8 9import { logger } from '#/logger'; 10 11import { AccountList } from '#/components/AccountList'; 12import * as Dialog from '#/components/Dialog'; 13import { type SigninDialogPayload, useGlobalDialogsHandleContext } from '#/components/dialogs/Context'; 14import * as css from '#/components/dialogs/Signin.css'; 15import { At_Stroke2_Corner0_Rounded as AtIcon } from '#/components/icons/At'; 16import { ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeftIcon } from '#/components/icons/Chevron'; 17import { Spinner } from '#/components/Spinner'; 18import { Stack } from '#/components/Stack'; 19import { Text } from '#/components/Text'; 20import * as TextField from '#/components/TextField'; 21import * as Toast from '#/components/Toast'; 22import { Button, ButtonIcon, ButtonText } from '#/components/web/Button'; 23 24import { m } from '#/paraglide/messages'; 25import { colors } from '#/styles/colors'; 26 27export function SigninDialog() { 28 const { signinDialogHandle } = useGlobalDialogsHandleContext(); 29 return ( 30 <Dialog.Root handle={signinDialogHandle}> 31 {({ payload }: { payload: SigninDialogPayload | undefined }) => 32 payload ? ( 33 <Dialog.Popup size="narrow"> 34 <SigninDialogInner close={() => signinDialogHandle.close()} payload={payload} /> 35 </Dialog.Popup> 36 ) : null 37 } 38 </Dialog.Root> 39 ); 40} 41 42function SigninDialogInner({ close, payload }: { close: () => void; payload: SigninDialogPayload }) { 43 const { accounts } = useSession(); 44 const requestedAccount = payload.requestedAccount; 45 const showStoredAccounts = payload.showStoredAccounts ?? true; 46 const hasStoredAccounts = showStoredAccounts && accounts.length > 0; 47 48 // The render-prop subtree remounts on each open, so the entry screen is derived once from the 49 // payload: stored accounts get the chooser, everything else the new-account form. 50 const [screen, setScreen] = useState<'choose' | 'new'>(() => 51 hasStoredAccounts && !requestedAccount ? 'choose' : 'new', 52 ); 53 54 if (screen === 'choose') { 55 return ( 56 <ChooseAccountScreen 57 close={close} 58 intent={payload.intent ?? 'signin'} 59 onSelectOther={() => setScreen('new')} 60 /> 61 ); 62 } 63 return ( 64 <NewAccountScreen 65 initialHandle={requestedAccount?.handle ?? ''} 66 onBack={hasStoredAccounts ? () => setScreen('choose') : undefined} 67 /> 68 ); 69} 70 71function ChooseAccountScreen({ 72 close, 73 intent, 74 onSelectOther, 75}: { 76 close: () => void; 77 intent: 'signin' | 'switch'; 78 onSelectOther: () => void; 79}) { 80 const { currentAccount } = useSession(); 81 const [pendingDid, setPendingDid] = useState<string | null>(null); 82 83 const onSelectAccount = useCallback( 84 async (account: SessionAccount) => { 85 if (pendingDid) { 86 return; 87 } 88 if (account.did === currentAccount?.did) { 89 close(); 90 Toast.show(m['components.dialogs.account.alreadySignedIn']({ handle: account.handle })); 91 return; 92 } 93 try { 94 setPendingDid(account.did); 95 await switchAccount(account); 96 } catch (e) { 97 logger.error('sign in dialog: resume account failed', { 98 message: errorMessage(e), 99 }); 100 await login({ identifier: account.did }); 101 } finally { 102 setPendingDid(null); 103 } 104 }, 105 [close, currentAccount?.did, pendingDid], 106 ); 107 108 return ( 109 <Stack gap="lg"> 110 <Stack gap="xs"> 111 <Dialog.TitleRow> 112 <Dialog.Title> 113 {intent === 'switch' ? m['common.account.action.switch']() : m['common.session.action.signIn']()} 114 </Dialog.Title> 115 <Dialog.Close /> 116 </Dialog.TitleRow> 117 118 {intent !== 'switch' && ( 119 <Text color="textContrastMedium">{m['components.dialogs.account.chooseDescription']()}</Text> 120 )} 121 </Stack> 122 123 <AccountList 124 onSelectAccount={(account) => void onSelectAccount(account)} 125 onSelectOther={onSelectOther} 126 otherLabel={m['components.dialogs.account.signInAnother']()} 127 pendingDid={pendingDid} 128 /> 129 </Stack> 130 ); 131} 132 133function NewAccountScreen({ initialHandle, onBack }: { initialHandle: string; onBack?: () => void }) { 134 const [identifier, setIdentifier] = useState(initialHandle); 135 const [isSubmitting, setIsSubmitting] = useState(false); 136 const [error, setError] = useState(''); 137 138 const onSubmit = async () => { 139 // people habitually type the leading `@`, and the field renders one as a prefix icon 140 const trimmed = identifier.trim().replace(/^@/, ''); 141 if (!isActorIdentifier(trimmed)) { 142 setError(m['components.dialogs.account.handle.description']()); 143 return; 144 } 145 146 setError(''); 147 setIsSubmitting(true); 148 try { 149 await login({ identifier: trimmed }); 150 } catch (e) { 151 logger.error('sign in dialog: OAuth start failed', { 152 message: errorMessage(e), 153 }); 154 setError(m['components.dialogs.signin.startError']()); 155 setIsSubmitting(false); 156 } 157 }; 158 159 const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { 160 if (e.key === 'Enter') { 161 e.preventDefault(); 162 void onSubmit(); 163 } 164 }; 165 166 return ( 167 <Stack gap="xl"> 168 <Stack gap="xs"> 169 <Dialog.TitleRow> 170 <Dialog.Title>{m['common.session.action.signIn']()}</Dialog.Title> 171 <Dialog.Close /> 172 </Dialog.TitleRow> 173 174 <Text color="textContrastMedium">{m['components.dialogs.signin.description']()}</Text> 175 </Stack> 176 177 <TextField.Root isInvalid={!!error}> 178 <TextField.LabelText>{m['components.dialogs.account.handle.label']()}</TextField.LabelText> 179 <div className={css.field}> 180 <AtIcon className={css.fieldIcon} size="lg" fill={colors.contrast_500} /> 181 <TextField.Input 182 autoCapitalize="none" 183 autoFocus 184 className={css.fieldInput} 185 label={m['components.dialogs.account.handle.label']()} 186 onChangeText={setIdentifier} 187 onKeyDown={onKeyDown} 188 placeholder={m['components.dialogs.account.handle.placeholder']()} 189 value={identifier} 190 /> 191 </div> 192 193 {error && ( 194 <Text className={css.error} color="textContrastMedium" size="sm"> 195 {error} 196 </Text> 197 )} 198 </TextField.Root> 199 200 <Dialog.Actions direction="column"> 201 <Button 202 color="primary" 203 disabled={isSubmitting} 204 label={m['common.session.action.signIn']()} 205 onClick={() => void onSubmit()} 206 variant="solid" 207 size="large" 208 > 209 {isSubmitting ? ( 210 <Spinner color="white" label={m['common.status.loading']()} size="sm" /> 211 ) : ( 212 <ButtonText>{m['common.session.action.signIn']()}</ButtonText> 213 )} 214 </Button> 215 216 {onBack && ( 217 <Button 218 color="secondary" 219 disabled={isSubmitting} 220 label={m['components.dialogs.account.back']()} 221 onClick={onBack} 222 variant="ghost" 223 size="large" 224 > 225 <ButtonIcon icon={ChevronLeftIcon} /> 226 <ButtonText>{m['components.dialogs.account.back']()}</ButtonText> 227 </Button> 228 )} 229 </Dialog.Actions> 230 </Stack> 231 ); 232}