Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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