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