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