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