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