Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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