Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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}