Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at main 855 lines 24 kB view raw
1import { 2 useCallback, 3 useLayoutEffect, 4 useMemo, 5 useReducer, 6 useRef, 7 useState, 8} from 'react' 9import {LayoutAnimation, type TextInput, View} from 'react-native' 10import {moderateProfile, type ModerationOpts} from '@atproto/api' 11import {Trans, useLingui} from '@lingui/react/macro' 12 13import {sanitizeDisplayName} from '#/lib/strings/display-names' 14import {sanitizeHandle} from '#/lib/strings/handles' 15import {useModerationOpts} from '#/state/preferences/moderation-opts' 16import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 17import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 18import {useSession} from '#/state/session' 19import {type ListMethods} from '#/view/com/util/List' 20import {android, atoms as a, native, useTheme, web} from '#/alf' 21import {Button, ButtonIcon, ButtonText} from '#/components/Button' 22import * as Dialog from '#/components/Dialog' 23import {canBeMessaged} from '#/components/dms/util' 24import * as TextField from '#/components/forms/TextField' 25import * as Toggle from '#/components/forms/Toggle' 26import { 27 ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, 28 ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, 29} from '#/components/icons/Arrow' 30import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' 31import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' 32import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' 33import * as ProfileCard from '#/components/ProfileCard' 34import {Text} from '#/components/Typography' 35import {IS_NATIVE, IS_WEB} from '#/env' 36import type * as bsky from '#/types/bsky' 37import {ChatProfileTabs} from './ChatProfileTabs' 38import {EmptyMemberList} from './components/EmptyMemberList' 39import {GroupChatProfileCard} from './components/GroupChatProfileCard' 40import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' 41import {UserLabel} from './components/UserLabel' 42import {UserSearchInput} from './components/UserSearchInput' 43 44type NewGroupChatItem = { 45 type: 'newGroupChat' 46 key: string 47} 48 49type LabelItem = { 50 type: 'label' 51 key: string 52 message: string 53} 54 55type ProfileItem = { 56 type: 'profile' 57 key: string 58 profile: bsky.profile.AnyProfileView 59} 60 61type EmptyItem = { 62 type: 'empty' 63 key: string 64 message: string 65} 66 67type PlaceholderItem = { 68 type: 'placeholder' 69 key: string 70} 71 72type ErrorItem = { 73 type: 'error' 74 key: string 75} 76 77type Item = 78 | NewGroupChatItem 79 | LabelItem 80 | ProfileItem 81 | EmptyItem 82 | PlaceholderItem 83 | ErrorItem 84 85enum ChatState { 86 NEW_CHAT, 87 NEW_GROUP_CHAT, 88 GROUP_NAME, 89} 90 91export type State = { 92 chatState: ChatState 93 screenTitle: string 94 groupChatDids: string[] 95 groupChatProfiles: bsky.profile.AnyProfileView[] 96 groupName: string 97} 98 99export type Action = 100 | { 101 type: 'startNewGroupChat' 102 screenTitle: string 103 } 104 | { 105 type: 'setDids' 106 groupChatDids: string[] 107 groupChatProfiles: bsky.profile.AnyProfileView[] 108 } 109 | { 110 type: 'removeDids' 111 groupChatDids: string[] 112 groupChatProfiles: bsky.profile.AnyProfileView[] 113 } 114 | { 115 type: 'startNameGroup' 116 screenTitle: string 117 } 118 | { 119 type: 'nameGroup' 120 groupName: string 121 } 122 | { 123 type: 'goBackFromNewGroupChat' 124 screenTitle: string 125 } 126 | { 127 type: 'goBackFromGroupName' 128 screenTitle: string 129 } 130 131function reducer(state: State, action: Action): State { 132 switch (action.type) { 133 case 'startNewGroupChat': { 134 return { 135 ...state, 136 chatState: ChatState.NEW_GROUP_CHAT, 137 screenTitle: action.screenTitle, 138 groupChatDids: [], 139 groupChatProfiles: [], 140 groupName: '', 141 } 142 } 143 case 'setDids': { 144 return { 145 ...state, 146 groupChatDids: action.groupChatDids, 147 groupChatProfiles: action.groupChatProfiles, 148 } 149 } 150 case 'removeDids': { 151 return { 152 ...state, 153 groupChatDids: action.groupChatDids, 154 groupChatProfiles: action.groupChatProfiles, 155 } 156 } 157 case 'startNameGroup': { 158 return { 159 ...state, 160 chatState: ChatState.GROUP_NAME, 161 screenTitle: action.screenTitle, 162 } 163 } 164 case 'nameGroup': { 165 return { 166 ...state, 167 groupName: action.groupName, 168 } 169 } 170 case 'goBackFromNewGroupChat': { 171 return { 172 ...state, 173 chatState: ChatState.NEW_CHAT, 174 screenTitle: action.screenTitle, 175 groupChatDids: [], 176 groupChatProfiles: [], 177 groupName: '', 178 } 179 } 180 case 'goBackFromGroupName': { 181 return { 182 ...state, 183 chatState: ChatState.NEW_GROUP_CHAT, 184 screenTitle: action.screenTitle, 185 groupName: '', 186 } 187 } 188 } 189} 190 191export function InitiateChatFlow({ 192 title, 193 onSelectChat, 194 onSelectGroupChat, 195}: { 196 title: string 197 onSelectChat: (did: string) => void 198 onSelectGroupChat: (dids: string[], groupName: string) => void 199}) { 200 const t = useTheme() 201 const {t: l} = useLingui() 202 const moderationOpts = useModerationOpts() 203 const control = Dialog.useDialogContext() 204 const [headerHeight, setHeaderHeight] = useState(0) 205 const [footerHeight, setFooterHeight] = useState(0) 206 const listRef = useRef<ListMethods>(null) 207 const {currentAccount} = useSession() 208 const inputRef = useRef<TextInput>(null) 209 210 const [searchText, setSearchText] = useState('') 211 212 const { 213 data: results, 214 isError, 215 isFetching, 216 } = useActorAutocompleteQuery(searchText, true, 12) 217 const {data: follows} = useProfileFollowsQuery(currentAccount?.did) 218 219 const [ 220 {chatState, screenTitle, groupChatDids, groupChatProfiles, groupName}, 221 dispatch, 222 ] = useReducer(reducer, { 223 chatState: ChatState.NEW_CHAT, 224 screenTitle: title, 225 groupChatDids: [], 226 groupChatProfiles: [], 227 groupName: '', 228 }) 229 230 const newGroupChatTitle = l`New group chat` 231 const groupNameTitle = l`Group name` 232 233 const onRemoveDid = useCallback( 234 (did: string) => { 235 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 236 dispatch({ 237 type: 'removeDids', 238 groupChatDids: groupChatDids.filter(d => d !== did), 239 groupChatProfiles: groupChatProfiles.filter( 240 profile => profile.did !== did, 241 ), 242 }) 243 }, 244 [groupChatDids, groupChatProfiles], 245 ) 246 247 const items = useMemo(() => { 248 let _items: Item[] = [] 249 250 if (isError) { 251 _items.push({ 252 type: 'empty', 253 key: 'empty', 254 message: l`We’re having network issues, try again`, 255 }) 256 } else if (chatState === ChatState.GROUP_NAME) { 257 _items = groupChatProfiles.map(profile => ({ 258 type: 'profile', 259 key: profile.did, 260 profile, 261 })) 262 _items.unshift({ 263 type: 'label', 264 key: 'members', 265 message: l`New group chat with:`, 266 }) 267 } else if (searchText.length) { 268 if (results?.length) { 269 for (const profile of results) { 270 if (profile.did === currentAccount?.did) continue 271 _items.push({ 272 type: 'profile', 273 key: profile.did, 274 profile, 275 }) 276 } 277 278 _items = _items.sort(item => { 279 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 280 }) 281 } 282 } else { 283 const placeholders: Item[] = Array(10) 284 .fill(0) 285 .map((__, i) => ({ 286 type: 'placeholder', 287 key: i + '', 288 })) 289 290 if (follows) { 291 for (const page of follows.pages) { 292 for (const profile of page.follows) { 293 _items.push({ 294 type: 'profile', 295 key: profile.did, 296 profile, 297 }) 298 } 299 } 300 301 _items = _items.sort(item => { 302 return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 303 }) 304 } else { 305 _items.push(...placeholders) 306 } 307 } 308 309 if ( 310 searchText === '' && 311 (chatState === ChatState.NEW_CHAT || 312 chatState === ChatState.NEW_GROUP_CHAT) 313 ) { 314 _items.unshift({ 315 type: 'label', 316 key: 'suggested', 317 message: l`Suggested`, 318 }) 319 } 320 321 if (chatState === ChatState.NEW_CHAT && searchText === '') { 322 _items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) 323 } 324 325 return _items 326 }, [ 327 isError, 328 chatState, 329 searchText, 330 l, 331 groupChatProfiles, 332 results, 333 currentAccount?.did, 334 follows, 335 ]) 336 337 if (searchText && !isFetching && !items.length && !isError) { 338 items.push({type: 'empty', key: 'empty', message: l`No results`}) 339 } 340 341 const handlePressBack = useCallback(() => { 342 switch (chatState) { 343 case ChatState.NEW_CHAT: 344 control.close() 345 break 346 case ChatState.NEW_GROUP_CHAT: 347 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 348 dispatch({type: 'goBackFromNewGroupChat', screenTitle: title}) 349 setSearchText('') 350 break 351 case ChatState.GROUP_NAME: 352 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 353 dispatch({type: 'goBackFromGroupName', screenTitle: newGroupChatTitle}) 354 break 355 } 356 }, [chatState, control, newGroupChatTitle, title]) 357 358 const handlePressNewGroupChat = useCallback(() => { 359 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 360 dispatch({type: 'startNewGroupChat', screenTitle: newGroupChatTitle}) 361 }, [newGroupChatTitle]) 362 363 const handlePressNext = useCallback(() => { 364 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 365 dispatch({type: 'startNameGroup', screenTitle: groupNameTitle}) 366 setSearchText('') 367 }, [groupNameTitle]) 368 369 const handlePressConfirm = useCallback(() => { 370 onSelectGroupChat(groupChatDids, groupName) 371 }, [groupChatDids, groupName, onSelectGroupChat]) 372 373 const setGroupName = (newGroupName: string) => { 374 dispatch({type: 'nameGroup', groupName: newGroupName}) 375 } 376 377 const renderItems = useCallback( 378 ({item}: {item: Item}) => { 379 switch (item.type) { 380 case 'newGroupChat': { 381 return ( 382 <NewGroupChatButton 383 key={item.key} 384 onPress={handlePressNewGroupChat} 385 /> 386 ) 387 } 388 case 'label': { 389 return <UserLabel key={item.key} message={item.message} /> 390 } 391 case 'profile': { 392 switch (chatState) { 393 case ChatState.NEW_CHAT: 394 return ( 395 <DefaultProfileCard 396 key={item.key} 397 profile={item.profile} 398 moderationOpts={moderationOpts!} 399 onPress={onSelectChat} 400 /> 401 ) 402 case ChatState.NEW_GROUP_CHAT: 403 return ( 404 <GroupChatProfileCard 405 key={item.key} 406 profile={item.profile} 407 moderationOpts={moderationOpts!} 408 /> 409 ) 410 case ChatState.GROUP_NAME: 411 return ( 412 <GroupChatMemberProfileCard 413 key={item.key} 414 profile={item.profile} 415 moderationOpts={moderationOpts!} 416 /> 417 ) 418 } 419 } 420 case 'placeholder': { 421 return <ProfileCardSkeleton key={item.key} /> 422 } 423 case 'empty': { 424 return <EmptyMemberList key={item.key} message={item.message} /> 425 } 426 default: 427 return null 428 } 429 }, 430 [chatState, handlePressNewGroupChat, moderationOpts, onSelectChat], 431 ) 432 433 useLayoutEffect(() => { 434 if (IS_WEB) { 435 setImmediate(() => { 436 inputRef?.current?.focus() 437 }) 438 } 439 }, []) 440 441 let buttonLabel = l`Continue to group name` 442 let buttonText = l`Next` 443 let handleButtonPress = handlePressNext 444 let showButton = 445 chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0 446 let isButtonDisabled = !showButton 447 switch (chatState) { 448 case ChatState.GROUP_NAME: 449 buttonLabel = l`Create group chat` 450 buttonText = l`Create` 451 handleButtonPress = handlePressConfirm 452 showButton = true 453 isButtonDisabled = groupName === '' 454 break 455 } 456 457 const showChatProfileTabs = 458 chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0 459 460 const listHeader = useMemo( 461 () => ( 462 <View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}> 463 <View 464 style={[ 465 a.relative, 466 web(a.pt_lg), 467 native(a.pt_4xl), 468 android({ 469 borderTopLeftRadius: a.rounded_md.borderRadius, 470 borderTopRightRadius: a.rounded_md.borderRadius, 471 }), 472 a.px_lg, 473 chatState !== ChatState.GROUP_NAME ? a.pb_xs : a.pb_lg, 474 chatState !== ChatState.GROUP_NAME && a.border_b, 475 t.atoms.border_contrast_low, 476 t.atoms.bg, 477 ]}> 478 <View 479 style={[ 480 a.flex_row, 481 a.gap_sm, 482 a.relative, 483 a.align_center, 484 a.justify_between, 485 web(a.pb_lg), 486 ]}> 487 {IS_NATIVE ? ( 488 <Button 489 label={l`Back`} 490 size="large" 491 shape="round" 492 variant="ghost" 493 color="secondary" 494 style={[native([a.absolute, a.z_20])]} 495 onPress={handlePressBack}> 496 <ButtonIcon icon={ArrowLeftIcon} size="lg" /> 497 </Button> 498 ) : null} 499 <Text 500 style={[ 501 a.flex_grow, 502 a.z_10, 503 a.text_lg, 504 a.font_bold, 505 a.leading_tight, 506 t.atoms.text_contrast_high, 507 a.text_center, 508 a.px_5xl, 509 ]}> 510 {screenTitle} 511 </Text> 512 {IS_WEB ? ( 513 <Button 514 label={l`Close`} 515 size="small" 516 shape="round" 517 variant="ghost" 518 color="secondary" 519 style={[a.absolute, a.z_20, {right: -4}]} 520 onPress={() => control.close()}> 521 <ButtonIcon icon={XIcon} size="lg" /> 522 </Button> 523 ) : showButton ? ( 524 <Button 525 label={buttonLabel} 526 size="small" 527 color="primary" 528 style={[ 529 native([ 530 a.absolute, 531 a.z_20, 532 { 533 right: 8, 534 }, 535 ]), 536 ]} 537 disabled={isButtonDisabled} 538 onPress={handleButtonPress}> 539 <ButtonText>{buttonText}</ButtonText> 540 </Button> 541 ) : null} 542 </View> 543 <View style={[web(a.pt_xs), native(a.pt_md)]}> 544 {chatState === ChatState.GROUP_NAME ? ( 545 <View 546 style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}> 547 <TextField.Root> 548 <TextField.Input 549 label={l`Group name`} 550 value={groupName} 551 returnKeyType="next" 552 keyboardAppearance={t.scheme} 553 selectTextOnFocus={IS_NATIVE} 554 autoFocus={false} 555 accessibilityRole="text" 556 autoCorrect={false} 557 autoComplete="off" 558 autoCapitalize="none" 559 onChangeText={setGroupName} 560 onSubmitEditing={ 561 isButtonDisabled ? undefined : handleButtonPress 562 } 563 /> 564 </TextField.Root> 565 </View> 566 ) : ( 567 <UserSearchInput 568 inputRef={inputRef} 569 value={searchText} 570 onChangeText={text => { 571 setSearchText(text) 572 listRef.current?.scrollToOffset({offset: 0, animated: false}) 573 }} 574 onEscape={control.close} 575 /> 576 )} 577 </View> 578 </View> 579 {showChatProfileTabs ? ( 580 <View style={[a.pb_sm, a.pt_md, t.atoms.bg]}> 581 <ChatProfileTabs 582 testID="newGroupChatMembers" 583 profiles={groupChatProfiles} 584 onRemove={onRemoveDid} 585 /> 586 </View> 587 ) : null} 588 </View> 589 ), 590 [ 591 chatState, 592 t.atoms.border_contrast_low, 593 t.atoms.bg, 594 t.atoms.text_contrast_high, 595 t.scheme, 596 l, 597 handlePressBack, 598 screenTitle, 599 showButton, 600 buttonLabel, 601 isButtonDisabled, 602 handleButtonPress, 603 buttonText, 604 groupName, 605 searchText, 606 control, 607 showChatProfileTabs, 608 groupChatProfiles, 609 onRemoveDid, 610 ], 611 ) 612 613 const setGroupChatMembers = (dids: string[]) => { 614 LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) 615 616 const added = dids.filter(d => !groupChatDids.includes(d)) 617 const removed = groupChatDids.filter(d => !dids.includes(d)) 618 const newDids = [ 619 ...groupChatDids.filter(d => !removed.includes(d)), 620 ...added, 621 ] 622 623 const kept = groupChatProfiles.filter(p => dids.includes(p.did)) 624 const keptDids = new Set(kept.map(p => p.did)) 625 const addedProfiles = items 626 .filter( 627 (item): item is ProfileItem => 628 item.type === 'profile' && 629 dids.includes(item.profile.did) && 630 !keptDids.has(item.profile.did), 631 ) 632 .map(item => item.profile) 633 .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did)) 634 635 dispatch({ 636 type: 'setDids', 637 groupChatDids: newDids, 638 groupChatProfiles: [...kept, ...addedProfiles], 639 }) 640 } 641 642 return ( 643 <Toggle.Group 644 values={groupChatDids} 645 onChange={setGroupChatMembers} 646 type="checkbox" 647 label={ 648 chatState === ChatState.NEW_GROUP_CHAT 649 ? l`Select group chat members` 650 : l`Start chat` 651 } 652 style={web([a.contents])}> 653 <Dialog.InnerFlatList 654 ref={listRef} 655 data={items} 656 renderItem={renderItems} 657 ListHeaderComponent={listHeader} 658 stickyHeaderIndices={[0]} 659 keyExtractor={(item: Item) => item.key} 660 style={[ 661 web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 662 native({height: '100%'}), 663 ]} 664 webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} 665 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 666 scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} 667 keyboardDismissMode="on-drag" 668 footer={ 669 IS_WEB && chatState !== ChatState.NEW_CHAT ? ( 670 <Dialog.FlatListFooter 671 onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}> 672 <View style={[a.flex_row, a.align_center, a.justify_between]}> 673 <Button 674 label={l`Back`} 675 size="small" 676 color="secondary" 677 onPress={handlePressBack}> 678 <ButtonIcon icon={ArrowLeftIcon} size="md" /> 679 <ButtonText> 680 {' '} 681 <Trans>Back</Trans> 682 </ButtonText> 683 </Button> 684 <Button 685 label={buttonLabel} 686 size="small" 687 color="primary" 688 disabled={isButtonDisabled} 689 onPress={handleButtonPress}> 690 <ButtonText>{buttonText} </ButtonText> 691 {chatState !== ChatState.GROUP_NAME ? ( 692 <ButtonIcon icon={ArrowRightIcon} size="md" /> 693 ) : null} 694 </Button> 695 </View> 696 </Dialog.FlatListFooter> 697 ) : null 698 } 699 /> 700 </Toggle.Group> 701 ) 702} 703 704function NewGroupChatButton({onPress}: {onPress: () => void}) { 705 const t = useTheme() 706 const {t: l} = useLingui() 707 708 const handleOnPress = () => { 709 onPress() 710 } 711 712 return ( 713 <Button label={l`New group chat`} onPress={handleOnPress}> 714 {({hovered, pressed, focused}) => ( 715 <View 716 style={[ 717 a.px_lg, 718 a.py_md, 719 a.flex_row, 720 a.flex_1, 721 a.justify_between, 722 a.align_center, 723 a.gap_sm, 724 pressed || focused || hovered ? t.atoms.bg_contrast_25 : t.atoms.bg, 725 ]}> 726 <View 727 style={[ 728 a.rounded_full, 729 a.justify_center, 730 a.align_center, 731 { 732 backgroundColor: t.palette.contrast_50, 733 padding: 12, 734 }, 735 ]}> 736 <PersonGroupIcon size="md" fill={t.palette.contrast_1000} /> 737 </View> 738 <View style={[a.flex_grow]}> 739 <Text 740 style={[a.text_md, a.font_medium, a.leading_snug, t.atoms.text]}> 741 <Trans>New group chat</Trans> 742 </Text> 743 </View> 744 <ChevronRightIcon size="md" fill={t.palette.contrast_1000} /> 745 </View> 746 )} 747 </Button> 748 ) 749} 750 751function DefaultProfileCard({ 752 profile, 753 moderationOpts, 754 onPress, 755}: { 756 profile: bsky.profile.AnyProfileView 757 moderationOpts: ModerationOpts 758 onPress: (did: string) => void 759}) { 760 const t = useTheme() 761 const {t: l} = useLingui() 762 const enabled = canBeMessaged(profile) 763 const moderation = moderateProfile(profile, moderationOpts) 764 const handle = sanitizeHandle(profile.handle, '@') 765 const displayName = sanitizeDisplayName( 766 profile.displayName || sanitizeHandle(profile.handle), 767 moderation.ui('displayName'), 768 ) 769 770 const handleOnPress = useCallback(() => { 771 onPress(profile.did) 772 }, [onPress, profile.did]) 773 774 return ( 775 <Button 776 disabled={!enabled} 777 label={l`Start chat with ${displayName}`} 778 onPress={handleOnPress}> 779 {({hovered, pressed, focused}) => ( 780 <View 781 style={[ 782 a.flex_1, 783 a.py_sm, 784 a.px_lg, 785 !enabled 786 ? {opacity: 0.5} 787 : pressed || focused || hovered 788 ? t.atoms.bg_contrast_25 789 : t.atoms.bg, 790 ]}> 791 <ProfileCard.Header> 792 <ProfileCard.Avatar 793 profile={profile} 794 moderationOpts={moderationOpts} 795 size={44} 796 disabledPreview 797 /> 798 <View style={[a.flex_1]}> 799 <ProfileCard.Name 800 profile={profile} 801 moderationOpts={moderationOpts} 802 /> 803 {enabled ? ( 804 <ProfileCard.Handle profile={profile} /> 805 ) : ( 806 <Text 807 style={[a.leading_snug, t.atoms.text_contrast_high]} 808 numberOfLines={2}> 809 <Trans>{handle} cant be messaged</Trans> 810 </Text> 811 )} 812 </View> 813 </ProfileCard.Header> 814 </View> 815 )} 816 </Button> 817 ) 818} 819 820function GroupChatMemberProfileCard({ 821 profile, 822 moderationOpts, 823}: { 824 profile: bsky.profile.AnyProfileView 825 moderationOpts: ModerationOpts 826}) { 827 const t = useTheme() 828 const enabled = canBeMessaged(profile) 829 const handle = sanitizeHandle(profile.handle, '@') 830 831 return ( 832 <View style={[a.flex_1, a.py_sm, a.px_lg, t.atoms.bg]}> 833 <ProfileCard.Header> 834 <ProfileCard.Avatar 835 profile={profile} 836 moderationOpts={moderationOpts} 837 size={44} 838 disabledPreview 839 /> 840 <View style={[a.flex_1]}> 841 <ProfileCard.Name profile={profile} moderationOpts={moderationOpts} /> 842 {enabled ? ( 843 <ProfileCard.Handle profile={profile} /> 844 ) : ( 845 <Text 846 style={[a.leading_snug, t.atoms.text_contrast_high]} 847 numberOfLines={2}> 848 <Trans>{handle} cant be messaged</Trans> 849 </Text> 850 )} 851 </View> 852 </ProfileCard.Header> 853 </View> 854 ) 855}