forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}