Bluesky app fork with some witchin' additions 馃挮
at feat/markdown-basic 846 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} from '@lingui/core/macro' 5import {useLingui} from '@lingui/react' 6import {Trans} from '@lingui/react/macro' 7 8import {wait} from '#/lib/async/wait' 9import {getLabelingServiceTitle} from '#/lib/moderation' 10import {useCallOnce} from '#/lib/once' 11import {sanitizeHandle} from '#/lib/strings/handles' 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 {useAnalytics} from '#/analytics' 34import {IS_NATIVE} from '#/env' 35import {useSubmitReportMutation} from './action' 36import { 37 BSKY_LABELER_ONLY_REPORT_REASONS, 38 BSKY_LABELER_ONLY_SUBJECT_TYPES, 39 NEW_TO_OLD_REASONS_MAP, 40 SUPPORT_PAGE, 41} from './const' 42import {useCopyForSubject} from './copy' 43import {initialState, reducer} from './state' 44import {type ReportDialogProps, type ReportSubject} from './types' 45import {parseReportSubject} from './utils/parseReportSubject' 46import { 47 type ReportCategoryConfig, 48 type ReportOption, 49 useReportOptions, 50} from './utils/useReportOptions' 51 52export {type ReportSubject} from './types' 53export {useDialogControl as useReportDialogControl} from '#/components/Dialog' 54 55export function useGlobalReportDialogControl() { 56 return useGlobalDialogsControlContext().reportDialogControl 57} 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 ax = useAnalytics() 70 const subject = React.useMemo( 71 () => (props.subject ? parseReportSubject(props.subject) : undefined), 72 [props.subject], 73 ) 74 const onClose = React.useCallback(() => { 75 ax.metric('reportDialog:close', {}) 76 }, [ax]) 77 return ( 78 <Dialog.Outer control={props.control} onClose={onClose}> 79 <Dialog.Handle /> 80 {subject ? <Inner {...props} subject={subject} /> : <Invalid />} 81 </Dialog.Outer> 82 ) 83} 84 85/** 86 * This should only be shown if the dialog is configured incorrectly by a 87 * developer, but nevertheless we should have a graceful fallback. 88 */ 89function Invalid() { 90 const {_} = useLingui() 91 return ( 92 <Dialog.ScrollableInner label={_(msg`Report dialog`)}> 93 <Text style={[a.font_bold, a.text_xl, a.leading_snug, a.pb_xs]}> 94 <Trans>Invalid report subject</Trans> 95 </Text> 96 <Text style={[a.text_md, a.leading_snug]}> 97 <Trans> 98 Something wasn't quite right with the data you're trying to report. 99 Please contact support. 100 </Trans> 101 </Text> 102 <Dialog.Close /> 103 </Dialog.ScrollableInner> 104 ) 105} 106 107function Inner(props: ReportDialogProps) { 108 const ax = useAnalytics() 109 const logger = ax.logger.useChild(ax.logger.Context.ReportDialog) 110 const t = useTheme() 111 const {_} = useLingui() 112 const ref = React.useRef<ScrollView>(null) 113 const { 114 data: allLabelers, 115 isLoading: isLabelerLoading, 116 error: labelersLoadError, 117 refetch: refetchLabelers, 118 } = useMyLabelersQuery({excludeNonConfigurableLabelers: true}) 119 const isLoading = useDelayedLoading(500, isLabelerLoading) 120 const copy = useCopyForSubject(props.subject) 121 const {categories, getCategory} = useReportOptions() 122 const [state, dispatch] = React.useReducer(reducer, initialState) 123 124 const enableSquareButtons = useEnableSquareButtons() 125 126 /** 127 * Submission handling 128 */ 129 const {mutateAsync: submitReport} = useSubmitReportMutation() 130 const [isPending, setPending] = React.useState(false) 131 const [isSuccess, setSuccess] = React.useState(false) 132 133 // some reasons ONLY go to Bluesky 134 const isBskyOnlyReason = state?.selectedOption?.reason 135 ? BSKY_LABELER_ONLY_REPORT_REASONS.has(state.selectedOption.reason) 136 : false 137 // some subjects ONLY go to Bluesky 138 const isBskyOnlySubject = BSKY_LABELER_ONLY_SUBJECT_TYPES.has( 139 props.subject.type, 140 ) 141 142 /** 143 * Labelers that support this `subject` and its NSID collection 144 */ 145 const supportedLabelers = React.useMemo(() => { 146 if (!allLabelers) return [] 147 return allLabelers 148 .filter(l => { 149 const subjectTypes: string[] | undefined = l.subjectTypes 150 if (subjectTypes === undefined) return true 151 if (props.subject.type === 'account') { 152 return subjectTypes.includes('account') 153 } else if (props.subject.type === 'convoMessage') { 154 return subjectTypes.includes('chat') 155 } else { 156 return subjectTypes.includes('record') 157 } 158 }) 159 .filter(l => { 160 const collections: string[] | undefined = l.subjectCollections 161 if (collections === undefined) return true 162 // all chat collections accepted, since only Bluesky handles chats 163 if (props.subject.type === 'convoMessage') return true 164 return collections.includes(props.subject.nsid) 165 }) 166 .filter(l => { 167 if (!state.selectedOption) return false 168 if (isBskyOnlyReason || isBskyOnlySubject) { 169 return l.creator.did === BSKY_LABELER_DID 170 } 171 const supportedReasonTypes: string[] | undefined = l.reasonTypes 172 if (supportedReasonTypes === undefined) return true 173 return ( 174 // supports new reason type 175 supportedReasonTypes.includes(state.selectedOption.reason) || // 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 <Dialog.Close /> 582 </Dialog.ScrollableInner> 583 ) 584} 585 586function ActionOnce({ 587 check, 588 callback, 589}: { 590 check: () => boolean 591 callback: () => void 592}) { 593 React.useEffect(() => { 594 if (check()) { 595 callback() 596 } 597 }, [check, callback]) 598 return null 599} 600 601function StepOuter({children}: {children: React.ReactNode}) { 602 return <View style={[a.gap_md, a.w_full]}>{children}</View> 603} 604 605function StepTitle({ 606 index, 607 title, 608 activeIndex1, 609}: { 610 index: number 611 title: string 612 activeIndex1: number 613}) { 614 const t = useTheme() 615 const active = activeIndex1 === index 616 const completed = activeIndex1 > index 617 const enableSquareButtons = useEnableSquareButtons() 618 return ( 619 <View style={[a.flex_row, a.gap_sm, a.pr_3xl]}> 620 <View 621 style={[ 622 a.justify_center, 623 a.align_center, 624 enableSquareButtons ? a.rounded_sm : a.rounded_full, 625 a.border, 626 { 627 width: 24, 628 height: 24, 629 backgroundColor: active 630 ? t.palette.primary_500 631 : completed 632 ? t.palette.primary_100 633 : t.atoms.bg_contrast_25.backgroundColor, 634 borderColor: active 635 ? t.palette.primary_500 636 : completed 637 ? t.palette.primary_400 638 : t.atoms.border_contrast_low.borderColor, 639 }, 640 ]}> 641 {completed ? ( 642 <Check width={12} /> 643 ) : ( 644 <Text 645 style={[ 646 a.font_bold, 647 a.text_center, 648 t.atoms.text, 649 { 650 color: active 651 ? 'white' 652 : completed 653 ? t.palette.primary_700 654 : t.atoms.text_contrast_medium.color, 655 fontVariant: ['tabular-nums'], 656 width: 24, 657 height: 24, 658 lineHeight: 24, 659 }, 660 ]}> 661 {index} 662 </Text> 663 )} 664 </View> 665 666 <Text 667 style={[ 668 a.flex_1, 669 a.font_bold, 670 a.text_lg, 671 a.leading_snug, 672 active ? t.atoms.text : t.atoms.text_contrast_medium, 673 { 674 top: 1, 675 }, 676 ]}> 677 {title} 678 </Text> 679 </View> 680 ) 681} 682 683function CategoryCard({ 684 option, 685 onSelect, 686}: { 687 option: ReportCategoryConfig 688 onSelect?: (option: ReportCategoryConfig) => void 689}) { 690 const t = useTheme() 691 const {_} = useLingui() 692 const gutters = useGutters(['compact']) 693 const onPress = React.useCallback(() => { 694 onSelect?.(option) 695 }, [onSelect, option]) 696 return ( 697 <Button 698 testID={`report:category:${option.title}`} 699 label={_(msg`Create report for ${option.title}`)} 700 onPress={onPress} 701 disabled={!onSelect}> 702 {({hovered, pressed}) => ( 703 <View 704 style={[ 705 a.w_full, 706 gutters, 707 a.py_sm, 708 a.rounded_sm, 709 a.border, 710 t.atoms.bg_contrast_25, 711 hovered || pressed 712 ? [t.atoms.border_contrast_high] 713 : [t.atoms.border_contrast_low], 714 ]}> 715 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 716 {option.title} 717 </Text> 718 <Text 719 style={[a.text_sm, , a.leading_snug, t.atoms.text_contrast_medium]}> 720 {option.description} 721 </Text> 722 </View> 723 )} 724 </Button> 725 ) 726} 727 728function OptionCard({ 729 option, 730 onSelect, 731}: { 732 option: ReportOption 733 onSelect?: (option: ReportOption) => void 734}) { 735 const t = useTheme() 736 const {_} = useLingui() 737 const gutters = useGutters(['compact']) 738 const onPress = React.useCallback(() => { 739 onSelect?.(option) 740 }, [onSelect, option]) 741 return ( 742 <Button 743 testID={`report:option:${option.title}`} 744 label={_( 745 msg({ 746 message: `Create report for ${option.title}`, 747 comment: 748 'Accessibility label for button to create a moderation report for the selected option', 749 }), 750 )} 751 onPress={onPress} 752 disabled={!onSelect}> 753 {({hovered, pressed}) => ( 754 <View 755 style={[ 756 a.w_full, 757 gutters, 758 a.py_sm, 759 a.rounded_sm, 760 a.border, 761 t.atoms.bg_contrast_25, 762 hovered || pressed 763 ? [t.atoms.border_contrast_high] 764 : [t.atoms.border_contrast_low], 765 ]}> 766 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 767 {option.title} 768 </Text> 769 </View> 770 )} 771 </Button> 772 ) 773} 774 775function OptionCardSkeleton() { 776 const t = useTheme() 777 return ( 778 <View 779 style={[ 780 a.w_full, 781 a.rounded_sm, 782 a.border, 783 t.atoms.bg_contrast_25, 784 t.atoms.border_contrast_low, 785 {height: 55}, // magic, based on web 786 ]} 787 /> 788 ) 789} 790 791function LabelerCard({ 792 labeler, 793 onSelect, 794}: { 795 labeler: AppBskyLabelerDefs.LabelerViewDetailed 796 onSelect?: (option: AppBskyLabelerDefs.LabelerViewDetailed) => void 797}) { 798 const t = useTheme() 799 const {_} = useLingui() 800 const onPress = React.useCallback(() => { 801 onSelect?.(labeler) 802 }, [onSelect, labeler]) 803 const title = getLabelingServiceTitle({ 804 displayName: labeler.creator.displayName, 805 handle: labeler.creator.handle, 806 }) 807 return ( 808 <Button 809 testID={`report:labeler:${labeler.creator.handle}`} 810 label={_(msg`Send report to ${title}`)} 811 onPress={onPress} 812 disabled={!onSelect}> 813 {({hovered, pressed}) => ( 814 <View 815 style={[ 816 a.w_full, 817 a.p_sm, 818 a.flex_row, 819 a.align_center, 820 a.gap_sm, 821 a.rounded_md, 822 a.border, 823 t.atoms.bg_contrast_25, 824 hovered || pressed 825 ? [t.atoms.border_contrast_high] 826 : [t.atoms.border_contrast_low], 827 ]}> 828 <UserAvatar 829 type="labeler" 830 size={36} 831 avatar={labeler.creator.avatar} 832 /> 833 <View style={[a.flex_1]}> 834 <Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}> 835 {title} 836 </Text> 837 <Text 838 style={[a.text_sm, a.leading_snug, t.atoms.text_contrast_medium]}> 839 <Trans>By {sanitizeHandle(labeler.creator.handle, '@')}</Trans> 840 </Text> 841 </View> 842 </View> 843 )} 844 </Button> 845 ) 846}