Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

1import React from 'react' 2import {Pressable, type ScrollView, View} from 'react-native' 3import {type AppBskyLabelerDefs, BSKY_LABELER_DID} from '@atproto/api' 4import {msg, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {wait} from '#/lib/async/wait' 8import {getLabelingServiceTitle} from '#/lib/moderation' 9import {sanitizeHandle} from '#/lib/strings/handles' 10import {Logger} from '#/logger' 11import {isNative} from '#/platform/detection' 12import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 13import {useMyLabelersQuery} from '#/state/queries/preferences' 14import {CharProgress} from '#/view/com/composer/char-progress/CharProgress' 15import {UserAvatar} from '#/view/com/util/UserAvatar' 16import {atoms as a, useGutters, useTheme} from '#/alf' 17import * as Admonition from '#/components/Admonition' 18import {Button, ButtonIcon, ButtonText} from '#/components/Button' 19import * as Dialog from '#/components/Dialog' 20import {useGlobalDialogsControlContext} from '#/components/dialogs/Context' 21import {useDelayedLoading} from '#/components/hooks/useDelayedLoading' 22import {ArrowRotateCounterClockwise_Stroke2_Corner0_Rounded as Retry} from '#/components/icons/ArrowRotate' 23import { 24 Check_Stroke2_Corner0_Rounded as CheckThin, 25 CheckThick_Stroke2_Corner0_Rounded as Check, 26} from '#/components/icons/Check' 27import {PaperPlane_Stroke2_Corner0_Rounded as PaperPlane} from '#/components/icons/PaperPlane' 28import {SquareArrowTopRight_Stroke2_Corner0_Rounded as SquareArrowTopRight} from '#/components/icons/SquareArrowTopRight' 29import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 30import {createStaticClick, InlineLinkText, Link} from '#/components/Link' 31import {Loader} from '#/components/Loader' 32import {Text} from '#/components/Typography' 33import {useSubmitReportMutation} from './action' 34import { 35 BSKY_LABELER_ONLY_REPORT_REASONS, 36 BSKY_LABELER_ONLY_SUBJECT_TYPES, 37 NEW_TO_OLD_REASONS_MAP, 38 SUPPORT_PAGE, 39} from './const' 40import {useCopyForSubject} from './copy' 41import {initialState, reducer} from './state' 42import {type ReportDialogProps, type ReportSubject} from './types' 43import {parseReportSubject} from './utils/parseReportSubject' 44import { 45 type ReportCategoryConfig, 46 type ReportOption, 47 useReportOptions, 48} from './utils/useReportOptions' 49 50export {type ReportSubject} from './types' 51export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 52 53export function useGlobalReportDialogControl() { 54 return useGlobalDialogsControlContext().reportDialogControl 55} 56 57const logger = Logger.create(Logger.Context.ReportDialog) 58 59export function GlobalReportDialog() { 60 const {value, control} = useGlobalReportDialogControl() 61 return <ReportDialog control={control} subject={value?.subject} /> 62} 63 64export function ReportDialog( 65 props: Omit<ReportDialogProps, 'subject'> & { 66 subject?: ReportSubject 67 }, 68) { 69 const subject = React.useMemo( 70 () => (props.subject ? parseReportSubject(props.subject) : undefined), 71 [props.subject], 72 ) 73 const onClose = React.useCallback(() => { 74 logger.metric('reportDialog:close', {}, {statsig: false}) 75 }, []) 76 return ( 77 <Dialog.Outer control={props.control} onClose={onClose}> 78 <Dialog.Handle /> 79 {subject ? <Inner {...props} subject={subject} /> : <Invalid />} 80 </Dialog.Outer> 81 ) 82} 83 84/** 85 * This should only be shown if the dialog is configured incorrectly by a 86 * developer, but nevertheless we should have a graceful fallback. 87 */ 88function Invalid() { 89 const {_} = useLingui() 90 return ( 91 <Dialog.ScrollableInner label={_(msg`Report dialog`)}> 92 <Text style={[a.font_bold, a.text_xl, a.leading_snug, a.pb_xs]}> 93 <Trans>Invalid report subject</Trans> 94 </Text> 95 <Text style={[a.text_md, a.leading_snug]}> 96 <Trans> 97 Something wasn't quite right with the data you're trying to report. 98 Please contact support. 99 </Trans> 100 </Text> 101 <Dialog.Close /> 102 </Dialog.ScrollableInner> 103 ) 104} 105 106function Inner(props: ReportDialogProps) { 107 const t = useTheme() 108 const {_} = useLingui() 109 const ref = React.useRef<ScrollView>(null) 110 const { 111 data: allLabelers, 112 isLoading: isLabelerLoading, 113 error: labelersLoadError, 114 refetch: refetchLabelers, 115 } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) 116 const isLoading = useDelayedLoading(500, isLabelerLoading) 117 const copy = useCopyForSubject(props.subject) 118 const {categories, getCategory} = useReportOptions() 119 const [state, dispatch] = React.useReducer(reducer, initialState) 120 121 const enableSquareButtons = useEnableSquareButtons() 122 123 /** 124 * Submission handling 125 */ 126 const {mutateAsync: submitReport} = useSubmitReportMutation() 127 const [isPending, setPending] = React.useState(false) 128 const [isSuccess, setSuccess] = React.useState(false) 129 130 // some reasons ONLY go to Bluesky 131 const isBskyOnlyReason = state?.selectedOption?.reason 132 ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) 133 : false 134 // some subjects ONLY go to Bluesky 135 const isBskyOnlySubject = BSKY_LABELER_ONLY_SUBJECT_TYPES.has( 136 props.subject.type, 137 ) 138 139 /** 140 * Labelers that support this `subject` and its NSID collection 141 */ 142 const supportedLabelers = React.useMemo(() => { 143 if (!allLabelers) return [] 144 return allLabelers 145 .filter(l => { 146 const subjectTypes: string[] | undefined = l.subjectTypes 147 if (subjectTypes === undefined) return true 148 if (props.subject.type === 'account') { 149 return subjectTypes.includes('account') 150 } else if (props.subject.type === 'convoMessage') { 151 return subjectTypes.includes('chat') 152 } else { 153 return subjectTypes.includes('record') 154 } 155 }) 156 .filter(l => { 157 const collections: string[] | undefined = l.subjectCollections 158 if (collections === undefined) return true 159 // all chat collections accepted, since only Bluesky handles chats 160 if (props.subject.type === 'convoMessage') return true 161 return collections.includes(props.subject.nsid) 162 }) 163 .filter(l => { 164 if (!state.selectedOption) return false 165 if (isBskyOnlyReason || isBskyOnlySubject) { 166 return l.creator.did === BSKY_LABELER_DID 167 } 168 const supportedReasonTypes: string[] | undefined = l.reasonTypes 169 if (supportedReasonTypes === undefined) return true 170 return ( 171 // supports new reason type 172 supportedReasonTypes.includes(state.selectedOption.reason) || 173 // supports old reason type (backwards compat) 174 supportedReasonTypes.includes( 175 NEW_TO_OLD_REASONS_MAP[state.selectedOption.reason], 176 ) 177 ) 178 }) 179 }, [ 180 props, 181 allLabelers, 182 state.selectedOption, 183 isBskyOnlyReason, 184 isBskyOnlySubject, 185 ]) 186 const hasSupportedLabelers = !!supportedLabelers.length 187 const hasSingleSupportedLabeler = supportedLabelers.length === 1 188 189 /** 190 * We skip the select labeler step if there's only one possible labeler, and 191 * that labeler is Bluesky (which is the case for chat reports and certain 192 * reason types). We'll use this below to adjust the indexing and skip the 193 * step in the UI. 194 */ 195 const isAlwaysBskyLabeler = 196 hasSingleSupportedLabeler && (isBskyOnlyReason || isBskyOnlySubject) 197 198 const onSubmit = React.useCallback(async () => { 199 dispatch({type: 'clearError'}) 200 201 logger.info('submitting') 202 203 try { 204 setPending(true) 205 // wait at least 1s, make it feel substantial 206 await wait( 207 1e3, 208 submitReport({ 209 subject: props.subject, 210 state, 211 }), 212 ) 213 setSuccess(true) 214 logger.metric( 215 'reportDialog:success', 216 { 217 reason: state.selectedOption?.reason!, 218 labeler: state.selectedLabeler?.creator.handle!, 219 details: !!state.details, 220 }, 221 {statsig: false}, 222 ) 223 // give time for user feedback 224 setTimeout(() => { 225 props.control.close(() => { 226 props.onAfterSubmit?.() 227 }) 228 }, 1e3) 229 } catch (e: any) { 230 logger.metric('reportDialog:failure', {}, {statsig: false}) 231 logger.error(e, { 232 source: 'ReportDialog', 233 }) 234 dispatch({ 235 type: 'setError', 236 error: _(msg`Something went wrong. Please try again.`), 237 }) 238 } finally { 239 setPending(false) 240 } 241 }, [_, submitReport, state, dispatch, props, setPending, setSuccess]) 242 243 React.useEffect(() => { 244 logger.metric( 245 'reportDialog:open', 246 { 247 subjectType: props.subject.type, 248 }, 249 {statsig: false}, 250 ) 251 }, [props.subject]) 252 253 return ( 254 <Dialog.ScrollableInner 255 testID="report:dialog" 256 label={_(msg`Report dialog`)} 257 ref={ref} 258 style={[a.w_full, {maxWidth: 500}]}> 259 <View style={[a.gap_2xl, isNative && a.pt_md]}> 260 <StepOuter> 261 <StepTitle 262 index={1} 263 title={copy.subtitle} 264 activeIndex1={state.activeStepIndex1} 265 /> 266 {isLoading ? ( 267 <View style={[a.gap_sm]}> 268 <OptionCardSkeleton /> 269 <OptionCardSkeleton /> 270 <OptionCardSkeleton /> 271 <OptionCardSkeleton /> 272 <OptionCardSkeleton /> 273 {/* Here to capture focus for a hot sec to prevent flash */} 274 <Pressable accessible={false} /> 275 </View> 276 ) : labelersLoadError || !allLabelers ? ( 277 <Admonition.Outer type="error"> 278 <Admonition.Row> 279 <Admonition.Icon /> 280 <Admonition.Content> 281 <Admonition.Text> 282 <Trans>Something went wrong, please try again</Trans> 283 </Admonition.Text> 284 </Admonition.Content> 285 <Admonition.Button 286 color="negative_subtle" 287 label={_(msg`Retry loading report options`)} 288 onPress={() => refetchLabelers()}> 289 <ButtonText> 290 <Trans>Retry</Trans> 291 </ButtonText> 292 <ButtonIcon icon={Retry} /> 293 </Admonition.Button> 294 </Admonition.Row> 295 </Admonition.Outer> 296 ) : ( 297 <> 298 {state.selectedCategory ? ( 299 <View style={[a.flex_row, a.align_center, a.gap_md]}> 300 <View style={[a.flex_1]}> 301 <CategoryCard option={state.selectedCategory} /> 302 </View> 303 <Button 304 testID="report:clearCategory" 305 label={_(msg`Change report category`)} 306 size="tiny" 307 variant="solid" 308 color="secondary" 309 shape={enableSquareButtons ? 'square' : 'round'} 310 onPress={() => { 311 dispatch({type: 'clearCategory'}) 312 }}> 313 <ButtonIcon icon={X} /> 314 </Button> 315 </View> 316 ) : ( 317 <View style={[a.gap_sm]}> 318 {categories.map(o => ( 319 <CategoryCard 320 key={o.key} 321 option={o} 322 onSelect={() => { 323 dispatch({ 324 type: 'selectCategory', 325 option: o, 326 otherOption: getCategory('other').options[0], 327 }) 328 }} 329 /> 330 ))} 331 332 {['post', 'account'].includes(props.subject.type) && ( 333 <Link 334 to={SUPPORT_PAGE} 335 label={_( 336 msg`Need to report a copyright violation, legal request, or regulatory compliance issue?`, 337 )}> 338 {({hovered, pressed}) => ( 339 <View 340 style={[ 341 a.flex_row, 342 a.align_center, 343 a.w_full, 344 a.px_md, 345 a.py_sm, 346 a.rounded_sm, 347 a.border, 348 hovered || pressed 349 ? [t.atoms.border_contrast_high] 350 : [t.atoms.border_contrast_low], 351 ]}> 352 <Text style={[a.flex_1, a.italic, a.leading_snug]}> 353 <Trans> 354 Need to report a copyright violation, legal 355 request, or regulatory compliance issue? 356 </Trans> 357 </Text> 358 <SquareArrowTopRight 359 size="sm" 360 fill={t.atoms.text.color} 361 /> 362 </View> 363 )} 364 </Link> 365 )} 366 </View> 367 )} 368 </> 369 )} 370 </StepOuter> 371 372 <StepOuter> 373 <StepTitle 374 index={2} 375 title={_(msg`Select a reason`)} 376 activeIndex1={state.activeStepIndex1} 377 /> 378 {state.selectedOption ? ( 379 <View style={[a.flex_row, a.align_center, a.gap_md]}> 380 <View style={[a.flex_1]}> 381 <OptionCard option={state.selectedOption} /> 382 </View> 383 <Button 384 testID="report:clearReportOption" 385 label={_(msg`Change report reason`)} 386 size="tiny" 387 variant="solid" 388 color="secondary" 389 shape={enableSquareButtons ? 'square' : 'round'} 390 onPress={() => { 391 dispatch({type: 'clearOption'}) 392 }}> 393 <ButtonIcon icon={X} /> 394 </Button> 395 </View> 396 ) : state.selectedCategory ? ( 397 <View style={[a.gap_sm]}> 398 {getCategory(state.selectedCategory.key).options.map(o => ( 399 <OptionCard 400 key={o.reason} 401 option={o} 402 onSelect={() => { 403 dispatch({type: 'selectOption', option: o}) 404 }} 405 /> 406 ))} 407 </View> 408 ) : null} 409 </StepOuter> 410 411 {isAlwaysBskyLabeler ? ( 412 <ActionOnce 413 check={() => !state.selectedLabeler} 414 callback={() => { 415 dispatch({ 416 type: 'selectLabeler', 417 labeler: supportedLabelers[0], 418 }) 419 }} 420 /> 421 ) : ( 422 <StepOuter> 423 <StepTitle 424 index={3} 425 title={_(msg`Select moderation service`)} 426 activeIndex1={state.activeStepIndex1} 427 /> 428 {state.activeStepIndex1 >= 3 && ( 429 <> 430 {state.selectedLabeler ? ( 431 <> 432 {hasSingleSupportedLabeler ? ( 433 <LabelerCard labeler={state.selectedLabeler} /> 434 ) : ( 435 <View style={[a.flex_row, a.align_center, a.gap_md]}> 436 <View style={[a.flex_1]}> 437 <LabelerCard labeler={state.selectedLabeler} /> 438 </View> 439 <Button 440 label={_(msg`Change moderation service`)} 441 size="tiny" 442 variant="solid" 443 color="secondary" 444 shape={enableSquareButtons ? 'square' : 'round'} 445 onPress={() => { 446 dispatch({type: 'clearLabeler'}) 447 }}> 448 <ButtonIcon icon={X} /> 449 </Button> 450 </View> 451 )} 452 </> 453 ) : ( 454 <> 455 {hasSupportedLabelers ? ( 456 <View style={[a.gap_sm]}> 457 {hasSingleSupportedLabeler ? ( 458 <> 459 <LabelerCard labeler={supportedLabelers[0]} /> 460 <ActionOnce 461 check={() => !state.selectedLabeler} 462 callback={() => { 463 dispatch({ 464 type: 'selectLabeler', 465 labeler: supportedLabelers[0], 466 }) 467 }} 468 /> 469 </> 470 ) : ( 471 <> 472 {supportedLabelers.map(l => ( 473 <LabelerCard 474 key={l.creator.did} 475 labeler={l} 476 onSelect={() => { 477 dispatch({type: 'selectLabeler', labeler: l}) 478 }} 479 /> 480 ))} 481 </> 482 )} 483 </View> 484 ) : ( 485 // should never happen in our app 486 <Admonition.Admonition type="warning"> 487 <Trans> 488 Unfortunately, none of your subscribed labelers 489 supports this report type. 490 </Trans> 491 </Admonition.Admonition> 492 )} 493 </> 494 )} 495 </> 496 )} 497 </StepOuter> 498 )} 499 500 <StepOuter> 501 <StepTitle 502 index={isAlwaysBskyLabeler ? 3 : 4} 503 title={_(msg`Submit report`)} 504 activeIndex1={ 505 isAlwaysBskyLabeler 506 ? state.activeStepIndex1 - 1 507 : state.activeStepIndex1 508 } 509 /> 510 {state.activeStepIndex1 === 4 && ( 511 <> 512 <View style={[a.pb_xs, a.gap_xs]}> 513 <Text style={[a.leading_snug, a.pb_xs]}> 514 <Trans> 515 Your report will be sent to{' '} 516 <Text style={[a.font_semi_bold, a.leading_snug]}> 517 {state.selectedLabeler?.creator.displayName} 518 </Text> 519 . 520 </Trans>{' '} 521 {!state.detailsOpen ? ( 522 <InlineLinkText 523 label={_(msg`Add more details (optional)`)} 524 {...createStaticClick(() => { 525 dispatch({type: 'showDetails'}) 526 })}> 527 <Trans>Add more details (optional)</Trans> 528 </InlineLinkText> 529 ) : null} 530 </Text> 531 532 {state.detailsOpen && ( 533 <View> 534 <Dialog.Input 535 testID="report:details" 536 multiline 537 value={state.details} 538 onChangeText={details => { 539 dispatch({type: 'setDetails', details}) 540 }} 541 label={_(msg`Additional details (limit 300 characters)`)} 542 style={{paddingRight: 60}} 543 numberOfLines={4} 544 /> 545 <View 546 style={[ 547 a.absolute, 548 a.flex_row, 549 a.align_center, 550 a.pr_md, 551 a.pb_sm, 552 { 553 bottom: 0, 554 right: 0, 555 }, 556 ]}> 557 <CharProgress count={state.details?.length || 0} /> 558 </View> 559 </View> 560 )} 561 </View> 562 <Button 563 testID="report:submit" 564 label={_(msg`Submit report`)} 565 size="large" 566 variant="solid" 567 color="primary" 568 disabled={isPending || isSuccess} 569 onPress={onSubmit}> 570 <ButtonText> 571 <Trans>Submit report</Trans> 572 </ButtonText> 573 <ButtonIcon 574 icon={isSuccess ? CheckThin : isPending ? Loader : PaperPlane} 575 /> 576 </Button> 577 578 {state.error && ( 579 <Admonition.Admonition type="error"> 580 {state.error} 581 </Admonition.Admonition> 582 )} 583 </> 584 )} 585 </StepOuter> 586 </View> 587 588 <Dialog.Close /> 589 </Dialog.ScrollableInner> 590 ) 591} 592 593function ActionOnce({ 594 check, 595 callback, 596}: { 597 check: () => boolean 598 callback: () => void 599}) { 600 React.useEffect(() => { 601 if (check()) { 602 callback() 603 } 604 }, [check, callback]) 605 return null 606} 607 608function StepOuter({children}: {children: React.ReactNode}) { 609 return <View style={[a.gap_md, a.w_full]}>{children}</View> 610} 611 612function StepTitle({ 613 index, 614 title, 615 activeIndex1, 616}: { 617 index: number 618 title: string 619 activeIndex1: number 620}) { 621 const t = useTheme() 622 const active = activeIndex1 === index 623 const completed = activeIndex1 > index 624 const enableSquareButtons = useEnableSquareButtons() 625 return ( 626 <View style={[a.flex_row, a.gap_sm, a.pr_3xl]}> 627 <View 628 style={[ 629 a.justify_center, 630 a.align_center, 631 enableSquareButtons ? a.rounded_sm : a.rounded_full, 632 a.border, 633 { 634 width: 24, 635 height: 24, 636 backgroundColor: active 637 ? t.palette.primary_500 638 : completed 639 ? t.palette.primary_100 640 : t.atoms.bg_contrast_25.backgroundColor, 641 borderColor: active 642 ? t.palette.primary_500 643 : completed 644 ? t.palette.primary_400 645 : t.atoms.border_contrast_low.borderColor, 646 }, 647 ]}> 648 {completed ? ( 649 <Check width={12} /> 650 ) : ( 651 <Text 652 style={[ 653 a.font_bold, 654 a.text_center, 655 t.atoms.text, 656 { 657 color: active 658 ? 'white' 659 : completed 660 ? t.palette.primary_700 661 : t.atoms.text_contrast_medium.color, 662 fontVariant: ['tabular-nums'], 663 width: 24, 664 height: 24, 665 lineHeight: 24, 666 }, 667 ]}> 668 {index} 669 </Text> 670 )} 671 </View> 672 673 <Text 674 style={[ 675 a.flex_1, 676 a.font_bold, 677 a.text_lg, 678 a.leading_snug, 679 active ? t.atoms.text : t.atoms.text_contrast_medium, 680 { 681 top: 1, 682 }, 683 ]}> 684 {title} 685 </Text> 686 </View> 687 ) 688} 689 690function CategoryCard({ 691 option, 692 onSelect, 693}: { 694 option: ReportCategoryConfig 695 onSelect?: (option: ReportCategoryConfig) => void 696}) { 697 const t = useTheme() 698 const {_} = useLingui() 699 const gutters = useGutters(['compact']) 700 const onPress = React.useCallback(() => { 701 onSelect?.(option) 702 }, [onSelect, option]) 703 return ( 704 <Button 705 testID={`report:category:${option.title}`} 706 label={_(msg`Create report for ${option.title}`)} 707 onPress={onPress} 708 disabled={!onSelect}> 709 {({hovered, pressed}) => ( 710 <View 711 style={[ 712 a.w_full, 713 gutters, 714 a.py_sm, 715 a.rounded_sm, 716 a.border, 717 t.atoms.bg_contrast_25, 718 hovered || pressed 719 ? [t.atoms.border_contrast_high] 720 : [t.atoms.border_contrast_low], 721 ]}> 722 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 723 {option.title} 724 </Text> 725 <Text 726 style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}> 727 {option.description} 728 </Text> 729 </View> 730 )} 731 </Button> 732 ) 733} 734 735function OptionCard({ 736 option, 737 onSelect, 738}: { 739 option: ReportOption 740 onSelect?: (option: ReportOption) => void 741}) { 742 const t = useTheme() 743 const {_} = useLingui() 744 const gutters = useGutters(['compact']) 745 const onPress = React.useCallback(() => { 746 onSelect?.(option) 747 }, [onSelect, option]) 748 return ( 749 <Button 750 testID={`report:option:${option.title}`} 751 label={_( 752 msg({ 753 message: `Create report for ${option.title}`, 754 comment: 755 'Accessibility label for button to create a moderation report for the selected option', 756 }), 757 )} 758 onPress={onPress} 759 disabled={!onSelect}> 760 {({hovered, pressed}) => ( 761 <View 762 style={[ 763 a.w_full, 764 gutters, 765 a.py_sm, 766 a.rounded_sm, 767 a.border, 768 t.atoms.bg_contrast_25, 769 hovered || pressed 770 ? [t.atoms.border_contrast_high] 771 : [t.atoms.border_contrast_low], 772 ]}> 773 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 774 {option.title} 775 </Text> 776 </View> 777 )} 778 </Button> 779 ) 780} 781 782function OptionCardSkeleton() { 783 const t = useTheme() 784 return ( 785 <View 786 style={[ 787 a.w_full, 788 a.rounded_sm, 789 a.border, 790 t.atoms.bg_contrast_25, 791 t.atoms.border_contrast_low, 792 {height: 55}, // magic, based on web 793 ]} 794 /> 795 ) 796} 797 798function LabelerCard({ 799 labeler, 800 onSelect, 801}: { 802 labeler: AppBskyLabelerDefs.LabelerViewDetailed 803 onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void 804}) { 805 const t = useTheme() 806 const {_} = useLingui() 807 const onPress = React.useCallback(() => { 808 onSelect?.(labeler) 809 }, [onSelect, labeler]) 810 const title = getLabelingServiceTitle({ 811 displayName: labeler.creator.displayName, 812 handle: labeler.creator.handle, 813 }) 814 return ( 815 <Button 816 testID={`report:labeler:${labeler.creator.handle}`} 817 label={_(msg`Send report to ${title}`)} 818 onPress={onPress} 819 disabled={!onSelect}> 820 {({hovered, pressed}) => ( 821 <View 822 style={[ 823 a.w_full, 824 a.p_sm, 825 a.flex_row, 826 a.align_center, 827 a.gap_sm, 828 a.rounded_md, 829 a.border, 830 t.atoms.bg_contrast_25, 831 hovered || pressed 832 ? [t.atoms.border_contrast_high] 833 : [t.atoms.border_contrast_low], 834 ]}> 835 <UserAvatar 836 type="labeler" 837 size={36} 838 avatar={labeler.creator.avatar} 839 /> 840 <View style={[a.flex_1]}> 841 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 842 {title} 843 </Text> 844 <Text 845 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 846 <Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans> 847 </Text> 848 </View> 849 </View> 850 )} 851 </Button> 852 ) 853}