personal fork of bluesky app
bsky.kelinci.net
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}