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