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} 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}