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