mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

at schema-errors 621 lines 17 kB view raw
1import React, {useState} from 'react' 2import { 3 ActivityIndicator, 4 StyleSheet, 5 TouchableOpacity, 6 View, 7} from 'react-native' 8import {setStringAsync} from 'expo-clipboard' 9import {ComAtprotoServerDescribeServer} from '@atproto/api' 10import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 11import {msg, Trans} from '@lingui/macro' 12import {useLingui} from '@lingui/react' 13 14import {logger} from '#/logger' 15import {useModalControls} from '#/state/modals' 16import {useFetchDid, useUpdateHandleMutation} from '#/state/queries/handle' 17import {useServiceQuery} from '#/state/queries/service' 18import {SessionAccount, useAgent, useSession} from '#/state/session' 19import {useAnalytics} from 'lib/analytics/analytics' 20import {usePalette} from 'lib/hooks/usePalette' 21import {cleanError} from 'lib/strings/errors' 22import {createFullHandle, makeValidHandle} from 'lib/strings/handles' 23import {s} from 'lib/styles' 24import {useTheme} from 'lib/ThemeContext' 25import {ErrorMessage} from '../util/error/ErrorMessage' 26import {Button} from '../util/forms/Button' 27import {SelectableBtn} from '../util/forms/SelectableBtn' 28import {Text} from '../util/text/Text' 29import * as Toast from '../util/Toast' 30import {ScrollView, TextInput} from './util' 31 32export const snapPoints = ['100%'] 33 34export type Props = {onChanged: () => void} 35 36export function Component(props: Props) { 37 const {currentAccount} = useSession() 38 const agent = useAgent() 39 const { 40 isLoading, 41 data: serviceInfo, 42 error: serviceInfoError, 43 } = useServiceQuery(agent.service.toString()) 44 45 return isLoading || !currentAccount ? ( 46 <View style={{padding: 18}}> 47 <ActivityIndicator /> 48 </View> 49 ) : serviceInfoError || !serviceInfo ? ( 50 <ErrorMessage message={cleanError(serviceInfoError)} /> 51 ) : ( 52 <Inner 53 {...props} 54 currentAccount={currentAccount} 55 serviceInfo={serviceInfo} 56 /> 57 ) 58} 59 60export function Inner({ 61 currentAccount, 62 serviceInfo, 63 onChanged, 64}: Props & { 65 currentAccount: SessionAccount 66 serviceInfo: ComAtprotoServerDescribeServer.OutputSchema 67}) { 68 const {_} = useLingui() 69 const pal = usePalette('default') 70 const {track} = useAnalytics() 71 const {closeModal} = useModalControls() 72 const {mutateAsync: updateHandle, isPending: isUpdateHandlePending} = 73 useUpdateHandleMutation() 74 const agent = useAgent() 75 76 const [error, setError] = useState<string>('') 77 78 const [isCustom, setCustom] = React.useState<boolean>(false) 79 const [handle, setHandle] = React.useState<string>('') 80 const [canSave, setCanSave] = React.useState<boolean>(false) 81 82 const userDomain = serviceInfo.availableUserDomains?.[0] 83 84 // events 85 // = 86 const onPressCancel = React.useCallback(() => { 87 closeModal() 88 }, [closeModal]) 89 const onToggleCustom = React.useCallback(() => { 90 // toggle between a provided domain vs a custom one 91 setHandle('') 92 setCanSave(false) 93 setCustom(!isCustom) 94 track( 95 isCustom ? 'EditHandle:ViewCustomForm' : 'EditHandle:ViewProvidedForm', 96 ) 97 }, [setCustom, isCustom, track]) 98 const onPressSave = React.useCallback(async () => { 99 if (!userDomain) { 100 logger.error(`ChangeHandle: userDomain is undefined`, { 101 service: serviceInfo, 102 }) 103 setError(`The service you've selected has no domains configured.`) 104 return 105 } 106 107 try { 108 track('EditHandle:SetNewHandle') 109 const newHandle = isCustom ? handle : createFullHandle(handle, userDomain) 110 logger.debug(`Updating handle to ${newHandle}`) 111 await updateHandle({ 112 handle: newHandle, 113 }) 114 await agent.resumeSession(agent.session!) 115 closeModal() 116 onChanged() 117 } catch (err: any) { 118 setError(cleanError(err)) 119 logger.error('Failed to update handle', {handle, message: err}) 120 } finally { 121 } 122 }, [ 123 setError, 124 handle, 125 userDomain, 126 isCustom, 127 onChanged, 128 track, 129 closeModal, 130 updateHandle, 131 serviceInfo, 132 agent, 133 ]) 134 135 // rendering 136 // = 137 return ( 138 <View style={[s.flex1, pal.view]}> 139 <View style={[styles.title, pal.border]}> 140 <View style={styles.titleLeft}> 141 <TouchableOpacity 142 onPress={onPressCancel} 143 accessibilityRole="button" 144 accessibilityLabel={_(msg`Cancel change handle`)} 145 accessibilityHint={_(msg`Exits handle change process`)} 146 onAccessibilityEscape={onPressCancel}> 147 <Text type="lg" style={pal.textLight}> 148 <Trans>Cancel</Trans> 149 </Text> 150 </TouchableOpacity> 151 </View> 152 <Text 153 type="2xl-bold" 154 style={[styles.titleMiddle, pal.text]} 155 numberOfLines={1}> 156 <Trans>Change Handle</Trans> 157 </Text> 158 <View style={styles.titleRight}> 159 {isUpdateHandlePending ? ( 160 <ActivityIndicator /> 161 ) : canSave ? ( 162 <TouchableOpacity 163 onPress={onPressSave} 164 accessibilityRole="button" 165 accessibilityLabel={_(msg`Save handle change`)} 166 accessibilityHint={_(msg`Saves handle change to ${handle}`)}> 167 <Text type="2xl-medium" style={pal.link}> 168 <Trans>Save</Trans> 169 </Text> 170 </TouchableOpacity> 171 ) : undefined} 172 </View> 173 </View> 174 <ScrollView style={styles.inner}> 175 {error !== '' && ( 176 <View style={styles.errorContainer}> 177 <ErrorMessage message={error} /> 178 </View> 179 )} 180 181 {isCustom ? ( 182 <CustomHandleForm 183 currentAccount={currentAccount} 184 handle={handle} 185 isProcessing={isUpdateHandlePending} 186 canSave={canSave} 187 onToggleCustom={onToggleCustom} 188 setHandle={setHandle} 189 setCanSave={setCanSave} 190 onPressSave={onPressSave} 191 /> 192 ) : ( 193 <ProvidedHandleForm 194 handle={handle} 195 userDomain={userDomain} 196 isProcessing={isUpdateHandlePending} 197 onToggleCustom={onToggleCustom} 198 setHandle={setHandle} 199 setCanSave={setCanSave} 200 /> 201 )} 202 </ScrollView> 203 </View> 204 ) 205} 206 207/** 208 * The form for using a domain allocated by the PDS 209 */ 210function ProvidedHandleForm({ 211 userDomain, 212 handle, 213 isProcessing, 214 setHandle, 215 onToggleCustom, 216 setCanSave, 217}: { 218 userDomain: string 219 handle: string 220 isProcessing: boolean 221 setHandle: (v: string) => void 222 onToggleCustom: () => void 223 setCanSave: (v: boolean) => void 224}) { 225 const pal = usePalette('default') 226 const theme = useTheme() 227 const {_} = useLingui() 228 229 // events 230 // = 231 const onChangeHandle = React.useCallback( 232 (v: string) => { 233 const newHandle = makeValidHandle(v) 234 setHandle(newHandle) 235 setCanSave(newHandle.length > 0) 236 }, 237 [setHandle, setCanSave], 238 ) 239 240 // rendering 241 // = 242 return ( 243 <> 244 <View style={[pal.btn, styles.textInputWrapper]}> 245 <FontAwesomeIcon 246 icon="at" 247 style={[pal.textLight, styles.textInputIcon]} 248 /> 249 <TextInput 250 testID="setHandleInput" 251 style={[pal.text, styles.textInput]} 252 placeholder={_(msg`e.g. alice`)} 253 placeholderTextColor={pal.colors.textLight} 254 autoCapitalize="none" 255 keyboardAppearance={theme.colorScheme} 256 value={handle} 257 onChangeText={onChangeHandle} 258 editable={!isProcessing} 259 accessible={true} 260 accessibilityLabel={_(msg`Handle`)} 261 accessibilityHint={_(msg`Sets Bluesky username`)} 262 /> 263 </View> 264 <Text type="md" style={[pal.textLight, s.pl10, s.pt10]}> 265 <Trans> 266 Your full handle will be{' '} 267 <Text type="md-bold" style={pal.textLight}> 268 @{createFullHandle(handle, userDomain)} 269 </Text> 270 </Trans> 271 </Text> 272 <TouchableOpacity 273 onPress={onToggleCustom} 274 accessibilityRole="button" 275 accessibilityLabel={_(msg`Hosting provider`)} 276 accessibilityHint={_(msg`Opens modal for using custom domain`)}> 277 <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> 278 <Trans>I have my own domain</Trans> 279 </Text> 280 </TouchableOpacity> 281 </> 282 ) 283} 284 285/** 286 * The form for using a custom domain 287 */ 288function CustomHandleForm({ 289 currentAccount, 290 handle, 291 canSave, 292 isProcessing, 293 setHandle, 294 onToggleCustom, 295 onPressSave, 296 setCanSave, 297}: { 298 currentAccount: SessionAccount 299 handle: string 300 canSave: boolean 301 isProcessing: boolean 302 setHandle: (v: string) => void 303 onToggleCustom: () => void 304 onPressSave: () => void 305 setCanSave: (v: boolean) => void 306}) { 307 const pal = usePalette('default') 308 const palSecondary = usePalette('secondary') 309 const palError = usePalette('error') 310 const theme = useTheme() 311 const {_} = useLingui() 312 const [isVerifying, setIsVerifying] = React.useState(false) 313 const [error, setError] = React.useState<string>('') 314 const [isDNSForm, setDNSForm] = React.useState<boolean>(true) 315 const fetchDid = useFetchDid() 316 // events 317 // = 318 const onPressCopy = React.useCallback(() => { 319 setStringAsync(isDNSForm ? `did=${currentAccount.did}` : currentAccount.did) 320 Toast.show(_(msg`Copied to clipboard`)) 321 }, [currentAccount, isDNSForm, _]) 322 const onChangeHandle = React.useCallback( 323 (v: string) => { 324 setHandle(v) 325 setCanSave(false) 326 }, 327 [setHandle, setCanSave], 328 ) 329 const onPressVerify = React.useCallback(async () => { 330 if (canSave) { 331 onPressSave() 332 } 333 try { 334 setIsVerifying(true) 335 setError('') 336 const did = await fetchDid(handle) 337 if (did === currentAccount.did) { 338 setCanSave(true) 339 } else { 340 setError(`Incorrect DID returned (got ${did})`) 341 } 342 } catch (err: any) { 343 setError(cleanError(err)) 344 logger.error('Failed to verify domain', {handle, error: err}) 345 } finally { 346 setIsVerifying(false) 347 } 348 }, [ 349 handle, 350 currentAccount, 351 setIsVerifying, 352 setCanSave, 353 setError, 354 canSave, 355 onPressSave, 356 fetchDid, 357 ]) 358 359 // rendering 360 // = 361 return ( 362 <> 363 <Text type="md" style={[pal.text, s.pb5, s.pl5]} nativeID="customDomain"> 364 <Trans>Enter the domain you want to use</Trans> 365 </Text> 366 <View style={[pal.btn, styles.textInputWrapper]}> 367 <FontAwesomeIcon 368 icon="at" 369 style={[pal.textLight, styles.textInputIcon]} 370 /> 371 <TextInput 372 testID="setHandleInput" 373 style={[pal.text, styles.textInput]} 374 placeholder={_(msg`e.g. alice.com`)} 375 placeholderTextColor={pal.colors.textLight} 376 autoCapitalize="none" 377 keyboardAppearance={theme.colorScheme} 378 value={handle} 379 onChangeText={onChangeHandle} 380 editable={!isProcessing} 381 accessibilityLabelledBy="customDomain" 382 accessibilityLabel={_(msg`Custom domain`)} 383 accessibilityHint={_(msg`Input your preferred hosting provider`)} 384 /> 385 </View> 386 <View style={styles.spacer} /> 387 388 <View style={[styles.selectableBtns]}> 389 <SelectableBtn 390 selected={isDNSForm} 391 label={_(msg`DNS Panel`)} 392 left 393 onSelect={() => setDNSForm(true)} 394 accessibilityHint={_(msg`Use the DNS panel`)} 395 style={s.flex1} 396 /> 397 <SelectableBtn 398 selected={!isDNSForm} 399 label={_(msg`No DNS Panel`)} 400 right 401 onSelect={() => setDNSForm(false)} 402 accessibilityHint={_(msg`Use a file on your server`)} 403 style={s.flex1} 404 /> 405 </View> 406 <View style={styles.spacer} /> 407 {isDNSForm ? ( 408 <> 409 <Text type="md" style={[pal.text, s.pb5, s.pl5]}> 410 <Trans>Add the following DNS record to your domain:</Trans> 411 </Text> 412 <View style={[styles.dnsTable, pal.btn]}> 413 <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> 414 <Trans>Host:</Trans> 415 </Text> 416 <View style={[styles.dnsValue]}> 417 <Text type="mono" style={[styles.monoText, pal.text]}> 418 _atproto 419 </Text> 420 </View> 421 <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> 422 <Trans>Type:</Trans> 423 </Text> 424 <View style={[styles.dnsValue]}> 425 <Text type="mono" style={[styles.monoText, pal.text]}> 426 TXT 427 </Text> 428 </View> 429 <Text type="md-medium" style={[styles.dnsLabel, pal.text]}> 430 <Trans>Value:</Trans> 431 </Text> 432 <View style={[styles.dnsValue]}> 433 <Text type="mono" style={[styles.monoText, pal.text]}> 434 did={currentAccount.did} 435 </Text> 436 </View> 437 </View> 438 <Text type="md" style={[pal.text, s.pt20, s.pl5]}> 439 <Trans>This should create a domain record at:</Trans> 440 </Text> 441 <Text type="mono" style={[styles.monoText, pal.text, s.pt5, s.pl5]}> 442 _atproto.{handle} 443 </Text> 444 </> 445 ) : ( 446 <> 447 <Text type="md" style={[pal.text, s.pb5, s.pl5]}> 448 <Trans>Upload a text file to:</Trans> 449 </Text> 450 <View style={[styles.valueContainer, pal.btn]}> 451 <View style={[styles.dnsValue]}> 452 <Text type="mono" style={[styles.monoText, pal.text]}> 453 https://{handle}/.well-known/atproto-did 454 </Text> 455 </View> 456 </View> 457 <View style={styles.spacer} /> 458 <Text type="md" style={[pal.text, s.pb5, s.pl5]}> 459 <Trans>That contains the following:</Trans> 460 </Text> 461 <View style={[styles.valueContainer, pal.btn]}> 462 <View style={[styles.dnsValue]}> 463 <Text type="mono" style={[styles.monoText, pal.text]}> 464 {currentAccount.did} 465 </Text> 466 </View> 467 </View> 468 </> 469 )} 470 471 <View style={styles.spacer} /> 472 <Button type="default" style={[s.p20, s.mb10]} onPress={onPressCopy}> 473 <Text type="xl" style={[pal.link, s.textCenter]}> 474 <Trans> 475 Copy {isDNSForm ? _(msg`Domain Value`) : _(msg`File Contents`)} 476 </Trans> 477 </Text> 478 </Button> 479 {canSave === true && ( 480 <View style={[styles.message, palSecondary.view]}> 481 <Text type="md-medium" style={palSecondary.text}> 482 <Trans>Domain verified!</Trans> 483 </Text> 484 </View> 485 )} 486 {error ? ( 487 <View style={[styles.message, palError.view]}> 488 <Text type="md-medium" style={palError.text}> 489 {error} 490 </Text> 491 </View> 492 ) : null} 493 <Button 494 type="primary" 495 style={[s.p20, isVerifying && styles.dimmed]} 496 onPress={onPressVerify}> 497 {isVerifying ? ( 498 <ActivityIndicator color="white" /> 499 ) : ( 500 <Text type="xl-medium" style={[s.white, s.textCenter]}> 501 {canSave 502 ? _(msg`Update to ${handle}`) 503 : isDNSForm 504 ? _(msg`Verify DNS Record`) 505 : _(msg`Verify Text File`)} 506 </Text> 507 )} 508 </Button> 509 <View style={styles.spacer} /> 510 <TouchableOpacity 511 onPress={onToggleCustom} 512 accessibilityLabel={_(msg`Use default provider`)} 513 accessibilityHint={_(msg`Use bsky.social as hosting provider`)}> 514 <Text type="md-medium" style={[pal.link, s.pl10, s.pt5]}> 515 <Trans>Nevermind, create a handle for me</Trans> 516 </Text> 517 </TouchableOpacity> 518 </> 519 ) 520} 521 522const styles = StyleSheet.create({ 523 inner: { 524 padding: 14, 525 }, 526 footer: { 527 padding: 14, 528 }, 529 spacer: { 530 height: 20, 531 }, 532 dimmed: { 533 opacity: 0.7, 534 }, 535 536 selectableBtns: { 537 flexDirection: 'row', 538 }, 539 540 title: { 541 flexDirection: 'row', 542 alignItems: 'center', 543 paddingTop: 25, 544 paddingHorizontal: 20, 545 paddingBottom: 15, 546 borderBottomWidth: 1, 547 }, 548 titleLeft: { 549 width: 80, 550 }, 551 titleRight: { 552 width: 80, 553 flexDirection: 'row', 554 justifyContent: 'flex-end', 555 }, 556 titleMiddle: { 557 flex: 1, 558 textAlign: 'center', 559 fontSize: 21, 560 }, 561 562 textInputWrapper: { 563 borderRadius: 8, 564 flexDirection: 'row', 565 alignItems: 'center', 566 }, 567 textInputIcon: { 568 marginLeft: 12, 569 }, 570 textInput: { 571 flex: 1, 572 width: '100%', 573 paddingVertical: 10, 574 paddingHorizontal: 8, 575 fontSize: 17, 576 letterSpacing: 0.25, 577 fontWeight: '400', 578 borderRadius: 10, 579 }, 580 581 valueContainer: { 582 borderRadius: 4, 583 paddingVertical: 16, 584 }, 585 586 dnsTable: { 587 borderRadius: 4, 588 paddingTop: 2, 589 paddingBottom: 16, 590 }, 591 dnsLabel: { 592 paddingHorizontal: 14, 593 paddingTop: 10, 594 }, 595 dnsValue: { 596 paddingHorizontal: 14, 597 borderRadius: 4, 598 }, 599 monoText: { 600 fontSize: 18, 601 lineHeight: 20, 602 }, 603 604 message: { 605 paddingHorizontal: 12, 606 paddingVertical: 10, 607 borderRadius: 8, 608 marginBottom: 10, 609 }, 610 611 btn: { 612 flexDirection: 'row', 613 alignItems: 'center', 614 justifyContent: 'center', 615 width: '100%', 616 borderRadius: 32, 617 padding: 10, 618 marginBottom: 10, 619 }, 620 errorContainer: {marginBottom: 10}, 621})