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