Bluesky app fork with some witchin' additions 💫
at linkat-integration 655 lines 22 kB view raw
1import {useCallback, useMemo, useState} from 'react' 2import {useWindowDimensions, View} from 'react-native' 3import Animated, { 4 FadeIn, 5 FadeOut, 6 LayoutAnimationConfig, 7 LinearTransition, 8 SlideInLeft, 9 SlideInRight, 10 SlideOutLeft, 11 SlideOutRight, 12} from 'react-native-reanimated' 13import {type ComAtprotoServerDescribeServer} from '@atproto/api' 14import {msg, Trans} from '@lingui/macro' 15import {useLingui} from '@lingui/react' 16import {useMutation, useQueryClient} from '@tanstack/react-query' 17 18import {HITSLOP_10, urls} from '#/lib/constants' 19import {cleanError} from '#/lib/strings/errors' 20import {createFullHandle, validateServiceHandle} from '#/lib/strings/handles' 21import {sanitizeHandle} from '#/lib/strings/handles' 22import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 23import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' 24import {RQKEY as RQKEY_PROFILE} from '#/state/queries/profile' 25import {useServiceQuery} from '#/state/queries/service' 26import {useCurrentAccountProfile} from '#/state/queries/useCurrentAccountProfile' 27import {useAgent, useSession} from '#/state/session' 28import {ErrorScreen} from '#/view/com/util/error/ErrorScreen' 29import {atoms as a, native, useBreakpoints, useTheme} from '#/alf' 30import {Admonition} from '#/components/Admonition' 31import {Button, ButtonIcon, ButtonText} from '#/components/Button' 32import * as Dialog from '#/components/Dialog' 33import * as SegmentedControl from '#/components/forms/SegmentedControl' 34import * as TextField from '#/components/forms/TextField' 35import { 36 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, 37 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, 38} from '#/components/icons/Arrow' 39import {At_Stroke2_Corner0_Rounded as AtIcon} from '#/components/icons/At' 40import {CheckThick_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check' 41import {SquareBehindSquare4_Stroke2_Corner0_Rounded as CopyIcon} from '#/components/icons/SquareBehindSquare4' 42import {InlineLinkText} from '#/components/Link' 43import {Loader} from '#/components/Loader' 44import {Text} from '#/components/Typography' 45import {useSimpleVerificationState} from '#/components/verification' 46import {CopyButton} from './CopyButton' 47 48export function ChangeHandleDialog({ 49 control, 50}: { 51 control: Dialog.DialogControlProps 52}) { 53 const {height} = useWindowDimensions() 54 55 return ( 56 <Dialog.Outer control={control} nativeOptions={{minHeight: height}}> 57 <ChangeHandleDialogInner /> 58 </Dialog.Outer> 59 ) 60} 61 62function ChangeHandleDialogInner() { 63 const control = Dialog.useDialogContext() 64 const {_} = useLingui() 65 const agent = useAgent() 66 const enableSquareButtons = useEnableSquareButtons() 67 const { 68 data: serviceInfo, 69 error: serviceInfoError, 70 refetch, 71 } = useServiceQuery(agent.serviceUrl.toString()) 72 73 const [page, setPage] = useState<'provided-handle' | 'own-handle'>( 74 'provided-handle', 75 ) 76 77 const cancelButton = useCallback( 78 () => ( 79 <Button 80 label={_(msg`Cancel`)} 81 onPress={() => control.close()} 82 size="small" 83 color="primary" 84 variant="ghost" 85 style={[enableSquareButtons ? a.rounded_sm : a.rounded_full]}> 86 <ButtonText style={[a.text_md]}> 87 <Trans>Cancel</Trans> 88 </ButtonText> 89 </Button> 90 ), 91 [control, _, enableSquareButtons], 92 ) 93 94 return ( 95 <Dialog.ScrollableInner 96 label={_(msg`Change Handle`)} 97 header={ 98 <Dialog.Header renderLeft={cancelButton}> 99 <Dialog.HeaderText> 100 <Trans>Change Handle</Trans> 101 </Dialog.HeaderText> 102 </Dialog.Header> 103 } 104 contentContainerStyle={[a.pt_0, a.px_0]}> 105 <View style={[a.flex_1, a.pt_lg, a.px_xl]}> 106 {serviceInfoError ? ( 107 <ErrorScreen 108 title={_(msg`Oops!`)} 109 message={_(msg`There was an issue fetching your service info`)} 110 details={cleanError(serviceInfoError)} 111 onPressTryAgain={refetch} 112 /> 113 ) : serviceInfo ? ( 114 <LayoutAnimationConfig skipEntering skipExiting> 115 {page === 'provided-handle' ? ( 116 <Animated.View 117 key={page} 118 entering={native(SlideInLeft)} 119 exiting={native(SlideOutLeft)}> 120 <ProvidedHandlePage 121 serviceInfo={serviceInfo} 122 goToOwnHandle={() => setPage('own-handle')} 123 /> 124 </Animated.View> 125 ) : ( 126 <Animated.View 127 key={page} 128 entering={native(SlideInRight)} 129 exiting={native(SlideOutRight)}> 130 <OwnHandlePage 131 goToServiceHandle={() => setPage('provided-handle')} 132 /> 133 </Animated.View> 134 )} 135 </LayoutAnimationConfig> 136 ) : ( 137 <View style={[a.flex_1, a.justify_center, a.align_center, a.py_4xl]}> 138 <Loader size="xl" /> 139 </View> 140 )} 141 </View> 142 </Dialog.ScrollableInner> 143 ) 144} 145 146function ProvidedHandlePage({ 147 serviceInfo, 148 goToOwnHandle, 149}: { 150 serviceInfo: ComAtprotoServerDescribeServer.OutputSchema 151 goToOwnHandle: () => void 152}) { 153 const {_} = useLingui() 154 const [subdomain, setSubdomain] = useState('') 155 const agent = useAgent() 156 const control = Dialog.useDialogContext() 157 const {currentAccount} = useSession() 158 const queryClient = useQueryClient() 159 const profile = useCurrentAccountProfile() 160 const verification = useSimpleVerificationState({ 161 profile, 162 }) 163 164 const { 165 mutate: changeHandle, 166 isPending, 167 error, 168 isSuccess, 169 } = useUpdateHandleMutation({ 170 onSuccess: () => { 171 if (currentAccount) { 172 queryClient.invalidateQueries({ 173 queryKey: RQKEY_PROFILE(currentAccount.did), 174 }) 175 } 176 agent.resumeSession(agent.session!).then(() => control.close()) 177 }, 178 }) 179 180 const host = serviceInfo.availableUserDomains[0] 181 182 const validation = useMemo( 183 () => validateServiceHandle(subdomain, host), 184 [subdomain, host], 185 ) 186 187 const isInvalid = 188 !validation.handleChars || 189 !validation.hyphenStartOrEnd || 190 !validation.totalLength 191 192 return ( 193 <LayoutAnimationConfig skipEntering> 194 <View style={[a.flex_1, a.gap_md]}> 195 {isSuccess && ( 196 <Animated.View entering={FadeIn} exiting={FadeOut}> 197 <SuccessMessage text={_(msg`Handle changed!`)} /> 198 </Animated.View> 199 )} 200 {error && ( 201 <Animated.View entering={FadeIn} exiting={FadeOut}> 202 <ChangeHandleError error={error} /> 203 </Animated.View> 204 )} 205 <Animated.View 206 layout={native(LinearTransition)} 207 style={[a.flex_1, a.gap_md]}> 208 {verification.isVerified && verification.role === 'default' && ( 209 <Admonition type="error"> 210 <Trans> 211 You are verified. You will lose your verification status if you 212 change your handle.{' '} 213 <InlineLinkText 214 label={_( 215 msg({ 216 message: `Learn more`, 217 context: `english-only-resource`, 218 }), 219 )} 220 to={urls.website.blog.initialVerificationAnnouncement}> 221 <Trans context="english-only-resource">Learn more.</Trans> 222 </InlineLinkText> 223 </Trans> 224 </Admonition> 225 )} 226 <View> 227 <TextField.LabelText> 228 <Trans>New handle</Trans> 229 </TextField.LabelText> 230 <TextField.Root isInvalid={isInvalid}> 231 <TextField.Icon icon={AtIcon} /> 232 <Dialog.Input 233 editable={!isPending} 234 defaultValue={subdomain} 235 onChangeText={text => setSubdomain(text)} 236 label={_(msg`New handle`)} 237 placeholder={_(msg`e.g. alice`)} 238 autoCapitalize="none" 239 autoCorrect={false} 240 /> 241 <TextField.SuffixText label={host} style={[{maxWidth: '40%'}]}> 242 {host} 243 </TextField.SuffixText> 244 </TextField.Root> 245 </View> 246 <Text> 247 <Trans> 248 Your full handle will be{' '} 249 <Text style={[a.font_semi_bold]}> 250 @{createFullHandle(subdomain, host)} 251 </Text> 252 </Trans> 253 </Text> 254 <Button 255 label={_(msg`Save new handle`)} 256 variant="solid" 257 size="large" 258 color={validation.overall ? 'primary' : 'secondary'} 259 disabled={!validation.overall} 260 onPress={() => { 261 if (validation.overall) { 262 changeHandle({handle: createFullHandle(subdomain, host)}) 263 } 264 }}> 265 {isPending ? ( 266 <ButtonIcon icon={Loader} /> 267 ) : ( 268 <ButtonText> 269 <Trans>Save</Trans> 270 </ButtonText> 271 )} 272 </Button> 273 <Text style={[a.leading_snug]}> 274 <Trans> 275 If you have your own domain, you can use that as your handle. This 276 lets you self-verify your identity.{' '} 277 <InlineLinkText 278 label={_( 279 msg({ 280 message: `Learn more`, 281 context: `english-only-resource`, 282 }), 283 )} 284 to="https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial" 285 style={[a.font_semi_bold]} 286 disableMismatchWarning> 287 Learn more here. 288 </InlineLinkText> 289 </Trans> 290 </Text> 291 <Button 292 label={_(msg`I have my own domain`)} 293 variant="outline" 294 color="primary" 295 size="large" 296 onPress={goToOwnHandle}> 297 <ButtonText> 298 <Trans>I have my own domain</Trans> 299 </ButtonText> 300 <ButtonIcon icon={ArrowRightIcon} position="right" /> 301 </Button> 302 </Animated.View> 303 </View> 304 </LayoutAnimationConfig> 305 ) 306} 307 308function OwnHandlePage({goToServiceHandle}: {goToServiceHandle: () => void}) { 309 const {_} = useLingui() 310 const t = useTheme() 311 const {currentAccount} = useSession() 312 const [dnsPanel, setDNSPanel] = useState(true) 313 const [domain, setDomain] = useState('') 314 const agent = useAgent() 315 const control = Dialog.useDialogContext() 316 const fetchDid = useFetchDid() 317 const queryClient = useQueryClient() 318 319 const { 320 mutate: changeHandle, 321 isPending, 322 error, 323 isSuccess, 324 } = useUpdateHandleMutation({ 325 onSuccess: () => { 326 if (currentAccount) { 327 queryClient.invalidateQueries({ 328 queryKey: RQKEY_PROFILE(currentAccount.did), 329 }) 330 } 331 agent.resumeSession(agent.session!).then(() => control.close()) 332 }, 333 }) 334 335 const { 336 mutate: verify, 337 isPending: isVerifyPending, 338 isSuccess: isVerified, 339 error: verifyError, 340 reset: resetVerification, 341 } = useMutation<true, Error | DidMismatchError>({ 342 mutationKey: ['verify-handle', domain], 343 mutationFn: async () => { 344 const did = await fetchDid(domain) 345 if (did !== currentAccount?.did) { 346 throw new DidMismatchError(did) 347 } 348 return true 349 }, 350 }) 351 352 return ( 353 <View style={[a.flex_1, a.gap_lg]}> 354 {isSuccess && ( 355 <Animated.View entering={FadeIn} exiting={FadeOut}> 356 <SuccessMessage text={_(msg`Handle changed!`)} /> 357 </Animated.View> 358 )} 359 {error && ( 360 <Animated.View entering={FadeIn} exiting={FadeOut}> 361 <ChangeHandleError error={error} /> 362 </Animated.View> 363 )} 364 {verifyError && ( 365 <Animated.View entering={FadeIn} exiting={FadeOut}> 366 <Admonition type="error"> 367 {verifyError instanceof DidMismatchError ? ( 368 <Trans> 369 Wrong DID returned from server. Received: {verifyError.did} 370 </Trans> 371 ) : ( 372 <Trans>Failed to verify handle. Please try again.</Trans> 373 )} 374 </Admonition> 375 </Animated.View> 376 )} 377 <Animated.View 378 layout={native(LinearTransition)} 379 style={[a.flex_1, a.gap_md, a.overflow_hidden]}> 380 <View> 381 <TextField.LabelText> 382 <Trans>Enter the domain you want to use</Trans> 383 </TextField.LabelText> 384 <TextField.Root> 385 <TextField.Icon icon={AtIcon} /> 386 <Dialog.Input 387 label={_(msg`New handle`)} 388 placeholder={_(msg`e.g. alice.com`)} 389 editable={!isPending} 390 defaultValue={domain} 391 onChangeText={text => { 392 setDomain(text) 393 resetVerification() 394 }} 395 autoCapitalize="none" 396 autoCorrect={false} 397 /> 398 </TextField.Root> 399 </View> 400 <SegmentedControl.Root 401 label={_(msg`Choose domain verification method`)} 402 type="tabs" 403 value={dnsPanel ? 'dns' : 'file'} 404 onChange={values => setDNSPanel(values === 'dns')}> 405 <SegmentedControl.Item value="dns" label={_(msg`DNS Panel`)}> 406 <SegmentedControl.ItemText> 407 <Trans>DNS Panel</Trans> 408 </SegmentedControl.ItemText> 409 </SegmentedControl.Item> 410 <SegmentedControl.Item value="file" label={_(msg`No DNS Panel`)}> 411 <SegmentedControl.ItemText> 412 <Trans>No DNS Panel</Trans> 413 </SegmentedControl.ItemText> 414 </SegmentedControl.Item> 415 </SegmentedControl.Root> 416 {dnsPanel ? ( 417 <> 418 <Text> 419 <Trans>Add the following DNS record to your domain:</Trans> 420 </Text> 421 <View 422 style={[ 423 t.atoms.bg_contrast_25, 424 a.rounded_sm, 425 a.p_md, 426 a.border, 427 t.atoms.border_contrast_low, 428 ]}> 429 <Text style={[t.atoms.text_contrast_medium]}> 430 <Trans>Host:</Trans> 431 </Text> 432 <View style={[a.py_xs]}> 433 <CopyButton 434 color="secondary" 435 value="_atproto" 436 label={_(msg`Copy host`)} 437 style={[a.bg_transparent]} 438 hoverStyle={[a.bg_transparent]} 439 hitSlop={HITSLOP_10}> 440 <Text style={[a.text_md, a.flex_1]}>_atproto</Text> 441 <ButtonIcon icon={CopyIcon} /> 442 </CopyButton> 443 </View> 444 <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}> 445 <Trans>Type:</Trans> 446 </Text> 447 <View style={[a.py_xs]}> 448 <Text style={[a.text_md]}>TXT</Text> 449 </View> 450 <Text style={[a.mt_xs, t.atoms.text_contrast_medium]}> 451 <Trans>Value:</Trans> 452 </Text> 453 <View style={[a.py_xs]}> 454 <CopyButton 455 color="secondary" 456 value={'did=' + currentAccount?.did} 457 label={_(msg`Copy TXT record value`)} 458 style={[a.bg_transparent]} 459 hoverStyle={[a.bg_transparent]} 460 hitSlop={HITSLOP_10}> 461 <Text style={[a.text_md, a.flex_1]}> 462 did={currentAccount?.did} 463 </Text> 464 <ButtonIcon icon={CopyIcon} /> 465 </CopyButton> 466 </View> 467 </View> 468 <Text> 469 <Trans>This should create a domain record at:</Trans> 470 </Text> 471 <View 472 style={[ 473 t.atoms.bg_contrast_25, 474 a.rounded_sm, 475 a.p_md, 476 a.border, 477 t.atoms.border_contrast_low, 478 ]}> 479 <Text style={[a.text_md]}>_atproto.{domain}</Text> 480 </View> 481 </> 482 ) : ( 483 <> 484 <Text> 485 <Trans>Upload a text file to:</Trans> 486 </Text> 487 <View 488 style={[ 489 t.atoms.bg_contrast_25, 490 a.rounded_sm, 491 a.p_md, 492 a.border, 493 t.atoms.border_contrast_low, 494 ]}> 495 <Text style={[a.text_md]}> 496 https://{domain}/.well-known/atproto-did 497 </Text> 498 </View> 499 <Text> 500 <Trans>That contains the following:</Trans> 501 </Text> 502 <CopyButton 503 value={currentAccount?.did ?? ''} 504 label={_(msg`Copy DID`)} 505 size="large" 506 shape="rectangular" 507 color="secondary" 508 style={[ 509 a.px_md, 510 a.border, 511 t.atoms.border_contrast_low, 512 t.atoms.bg_contrast_25, 513 ]}> 514 <Text style={[a.text_md, a.flex_1]}>{currentAccount?.did}</Text> 515 <ButtonIcon icon={CopyIcon} /> 516 </CopyButton> 517 </> 518 )} 519 </Animated.View> 520 {isVerified && ( 521 <Animated.View 522 entering={FadeIn} 523 exiting={FadeOut} 524 layout={native(LinearTransition)}> 525 <SuccessMessage text={_(msg`Domain verified!`)} /> 526 </Animated.View> 527 )} 528 <Animated.View layout={native(LinearTransition)}> 529 {currentAccount?.handle?.endsWith('.bsky.social') && ( 530 <Admonition type="info" style={[a.mb_md]}> 531 <Trans> 532 Your current handle{' '} 533 <Text style={[a.font_semi_bold]}> 534 {sanitizeHandle(currentAccount?.handle || '', '@')} 535 </Text>{' '} 536 will automatically remain reserved for you. You can switch back to 537 it at any time from this account. 538 </Trans> 539 </Admonition> 540 )} 541 <Button 542 label={ 543 isVerified 544 ? _(msg`Update to ${domain}`) 545 : dnsPanel 546 ? _(msg`Verify DNS Record`) 547 : _(msg`Verify Text File`) 548 } 549 variant="solid" 550 size="large" 551 color="primary" 552 disabled={domain.trim().length === 0} 553 onPress={() => { 554 if (isVerified) { 555 changeHandle({handle: domain}) 556 } else { 557 verify() 558 } 559 }}> 560 {isPending || isVerifyPending ? ( 561 <ButtonIcon icon={Loader} /> 562 ) : ( 563 <ButtonText> 564 {isVerified ? ( 565 <Trans>Update to {domain}</Trans> 566 ) : dnsPanel ? ( 567 <Trans>Verify DNS Record</Trans> 568 ) : ( 569 <Trans>Verify Text File</Trans> 570 )} 571 </ButtonText> 572 )} 573 </Button> 574 575 <Button 576 label={_(msg`Use default provider`)} 577 accessibilityHint={_(msg`Returns to previous page`)} 578 onPress={goToServiceHandle} 579 variant="outline" 580 color="secondary" 581 size="large" 582 style={[a.mt_sm]}> 583 <ButtonIcon icon={ArrowLeftIcon} position="left" /> 584 <ButtonText> 585 <Trans>Nevermind, create a handle for me</Trans> 586 </ButtonText> 587 </Button> 588 </Animated.View> 589 </View> 590 ) 591} 592 593class DidMismatchError extends Error { 594 did: string 595 constructor(did: string) { 596 super('DID mismatch') 597 this.name = 'DidMismatchError' 598 this.did = did 599 } 600} 601 602function ChangeHandleError({error}: {error: unknown}) { 603 const {_} = useLingui() 604 605 let message = _(msg`Failed to change handle. Please try again.`) 606 607 if (error instanceof Error) { 608 if (error.message.startsWith('Handle already taken')) { 609 message = _(msg`Handle already taken. Please try a different one.`) 610 } else if (error.message === 'Reserved handle') { 611 message = _(msg`This handle is reserved. Please try a different one.`) 612 } else if (error.message === 'Handle too long') { 613 message = _(msg`Handle too long. Please try a shorter one.`) 614 } else if (error.message === 'Input/handle must be a valid handle') { 615 message = _(msg`Invalid handle. Please try a different one.`) 616 } else if (error.message === 'Rate Limit Exceeded') { 617 message = _( 618 msg`Rate limit exceeded – you've tried to change your handle too many times in a short period. Please wait a minute before trying again.`, 619 ) 620 } 621 } 622 623 return <Admonition type="error">{message}</Admonition> 624} 625 626function SuccessMessage({text}: {text: string}) { 627 const {gtMobile} = useBreakpoints() 628 const t = useTheme() 629 const enableSquareButtons = useEnableSquareButtons() 630 return ( 631 <View 632 style={[ 633 a.flex_1, 634 a.gap_md, 635 a.flex_row, 636 a.justify_center, 637 a.align_center, 638 gtMobile ? a.px_md : a.px_sm, 639 a.py_xs, 640 t.atoms.border_contrast_low, 641 ]}> 642 <View 643 style={[ 644 {height: 20, width: 20}, 645 enableSquareButtons ? a.rounded_sm : a.rounded_full, 646 a.align_center, 647 a.justify_center, 648 {backgroundColor: t.palette.positive_500}, 649 ]}> 650 <CheckIcon fill={t.palette.white} size="xs" /> 651 </View> 652 <Text style={[a.text_md]}>{text}</Text> 653 </View> 654 ) 655}