Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at cope-settings-sync 699 lines 19 kB view raw
1import { 2 Fragment, 3 useCallback, 4 useLayoutEffect, 5 useMemo, 6 useRef, 7 useState, 8} from 'react' 9import {TextInput, View} from 'react-native' 10import {moderateProfile, type ModerationOpts} from '@atproto/api' 11import {Plural, Trans, useLingui} from '@lingui/react/macro' 12 13import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' 14import {sanitizeHandle} from '#/lib/strings/handles' 15import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 16import {useModerationOpts} from '#/state/preferences/moderation-opts' 17import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' 18import {useListConvosQuery} from '#/state/queries/messages/list-conversations' 19import {useProfileFollowsQuery} from '#/state/queries/profile-follows' 20import {useSession} from '#/state/session' 21import {type ListMethods} from '#/view/com/util/List' 22import {android, atoms as a, native, useTheme, web} from '#/alf' 23import {Button, ButtonIcon} from '#/components/Button' 24import * as Dialog from '#/components/Dialog' 25import { 26 canBeMessaged, 27 type ConvoWithDetails, 28 parseConvoView, 29} from '#/components/dms/util' 30import {useInteractionState} from '#/components/hooks/useInteractionState' 31import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' 32import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' 33import * as ProfileCard from '#/components/ProfileCard' 34import {Text} from '#/components/Typography' 35import {IS_WEB} from '#/env' 36import type * as bsky from '#/types/bsky' 37import {AvatarBubbles} from '../AvatarBubbles' 38import {Error} from '../Error' 39import {ProfileBadges} from '../ProfileBadges' 40 41export type ProfileItem = { 42 type: 'profile' 43 key: string 44 profile: bsky.profile.AnyProfileView 45} 46 47type ExistingChatItem = { 48 type: 'existingChat' 49 key: string 50 convo: ConvoWithDetails 51} 52 53type EmptyItem = { 54 type: 'empty' 55 key: string 56 message: string 57} 58 59type PlaceholderItem = { 60 type: 'placeholder' 61 key: string 62} 63 64type ErrorItem = { 65 type: 'error' 66 key: string 67} 68 69type Item = 70 | ProfileItem 71 | ExistingChatItem 72 | EmptyItem 73 | PlaceholderItem 74 | ErrorItem 75 76export function SearchablePeopleList({ 77 title, 78 showRecentConvos, 79 sortByMessageDeclaration, 80 excludeSelf = true, 81 onSelectChat, 82 renderProfileCard, 83}: { 84 title: string 85 showRecentConvos?: boolean 86 sortByMessageDeclaration?: boolean 87 excludeSelf?: boolean 88} & ( 89 | { 90 renderProfileCard: (item: ProfileItem) => React.ReactNode 91 onSelectChat?: undefined 92 } 93 | { 94 onSelectChat: ( 95 chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string}, 96 ) => void 97 renderProfileCard?: undefined 98 } 99)) { 100 const t = useTheme() 101 const {t: l} = useLingui() 102 const moderationOpts = useModerationOpts() 103 const control = Dialog.useDialogContext() 104 const [headerHeight, setHeaderHeight] = useState(0) 105 const listRef = useRef<ListMethods>(null) 106 const {currentAccount} = useSession() 107 const inputRef = useRef<TextInput>(null) 108 109 const [searchText, setSearchText] = useState('') 110 111 const enableSquareButtons = useEnableSquareButtons() 112 113 const { 114 data: results, 115 isError, 116 isFetching, 117 } = useActorAutocompleteQuery(searchText, true, 12) 118 const {data: follows} = useProfileFollowsQuery(currentAccount?.did) 119 const {data: convos} = useListConvosQuery({ 120 enabled: showRecentConvos, 121 status: 'accepted', 122 }) 123 124 const items = useMemo(() => { 125 let _items: Item[] = [] 126 127 if (isError) { 128 _items.push({ 129 type: 'empty', 130 key: 'empty', 131 message: l`We're having network issues, try again`, 132 }) 133 } else if (searchText.length) { 134 if (results?.length) { 135 for (const profile of results) { 136 if (excludeSelf && profile.did === currentAccount?.did) continue 137 _items.push({ 138 type: 'profile', 139 key: profile.did, 140 profile, 141 }) 142 } 143 144 if (sortByMessageDeclaration) { 145 _items = _items.sort(item => { 146 return item.type === 'profile' && canBeMessaged(item.profile) 147 ? -1 148 : 1 149 }) 150 } 151 } 152 } else { 153 const placeholders: Item[] = Array(10) 154 .fill(0) 155 .map((__, i) => ({ 156 type: 'placeholder', 157 key: i + '', 158 })) 159 160 if (showRecentConvos) { 161 if (convos && follows) { 162 const usedDids = new Set() 163 164 for (const page of convos.pages) { 165 for (const convoView of page.convos) { 166 const convo = parseConvoView(convoView, currentAccount?.did) 167 168 if (!convo) continue 169 170 if (convo.kind === 'group') { 171 _items.push({ 172 type: 'existingChat', 173 key: convo.view.id, 174 convo, 175 }) 176 } else { 177 if (convo.primaryMember.handle === 'missing.invalid') continue 178 if (usedDids.has(convo.primaryMember.did)) continue 179 180 usedDids.add(convo.primaryMember.did) 181 182 _items.push({ 183 type: 'existingChat', 184 key: convo.view.id, 185 convo: convo, 186 }) 187 } 188 } 189 } 190 191 let followsItems: ProfileItem[] = [] 192 193 for (const page of follows.pages) { 194 for (const profile of page.follows) { 195 if (usedDids.has(profile.did)) continue 196 197 followsItems.push({ 198 type: 'profile', 199 key: profile.did, 200 profile, 201 }) 202 } 203 } 204 205 if (sortByMessageDeclaration) { 206 // only sort follows 207 followsItems = followsItems.sort(item => { 208 return canBeMessaged(item.profile) ? -1 : 1 209 }) 210 } 211 212 // then append 213 _items.push(...followsItems) 214 } else { 215 _items.push(...placeholders) 216 } 217 } else if (follows) { 218 for (const page of follows.pages) { 219 for (const profile of page.follows) { 220 _items.push({ 221 type: 'profile', 222 key: profile.did, 223 profile, 224 }) 225 } 226 } 227 228 if (sortByMessageDeclaration) { 229 _items = _items.sort(item => { 230 return item.type === 'profile' && canBeMessaged(item.profile) 231 ? -1 232 : 1 233 }) 234 } 235 } else { 236 _items.push(...placeholders) 237 } 238 } 239 240 return _items 241 }, [ 242 l, 243 searchText, 244 results, 245 isError, 246 currentAccount?.did, 247 excludeSelf, 248 follows, 249 convos, 250 showRecentConvos, 251 sortByMessageDeclaration, 252 ]) 253 254 if (searchText && !isFetching && !items.length && !isError) { 255 items.push({type: 'empty', key: 'empty', message: l`No results`}) 256 } 257 258 const renderItems = useCallback( 259 ({item}: {item: Item}) => { 260 switch (item.type) { 261 case 'existingChat': { 262 if (renderProfileCard) { 263 // should be unreachable 264 return null 265 } else { 266 return ( 267 <ExistingChatCard 268 key={item.key} 269 convo={item.convo} 270 moderationOpts={moderationOpts!} 271 onPress={id => onSelectChat({kind: 'convo', id})} 272 /> 273 ) 274 } 275 } 276 case 'profile': { 277 if (renderProfileCard) { 278 return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment> 279 } else { 280 return ( 281 <DefaultProfileCard 282 key={item.key} 283 profile={item.profile} 284 moderationOpts={moderationOpts!} 285 onPress={did => onSelectChat({kind: 'user', did})} 286 /> 287 ) 288 } 289 } 290 case 'placeholder': { 291 return <ProfileCardSkeleton key={item.key} /> 292 } 293 case 'empty': { 294 return <Empty key={item.key} message={item.message} /> 295 } 296 case 'error': { 297 return <Error key={item.key} message={l`Failed to load profiles`} /> 298 } 299 default: 300 return null 301 } 302 }, 303 [moderationOpts, onSelectChat, renderProfileCard, l], 304 ) 305 306 useLayoutEffect(() => { 307 if (IS_WEB) { 308 setImmediate(() => { 309 inputRef?.current?.focus() 310 }) 311 } 312 }, []) 313 314 const listHeader = useMemo(() => { 315 return ( 316 <View 317 onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)} 318 style={[ 319 a.relative, 320 web(a.pt_lg), 321 native(a.pt_4xl), 322 android({ 323 borderTopLeftRadius: a.rounded_md.borderRadius, 324 borderTopRightRadius: a.rounded_md.borderRadius, 325 }), 326 a.pb_xs, 327 a.px_lg, 328 a.border_b, 329 t.atoms.border_contrast_low, 330 t.atoms.bg, 331 ]}> 332 <View style={[a.relative, native(a.align_center), a.justify_center]}> 333 <Text 334 style={[ 335 a.z_10, 336 a.text_lg, 337 a.font_bold, 338 a.leading_tight, 339 t.atoms.text_contrast_high, 340 ]}> 341 {title} 342 </Text> 343 {IS_WEB ? ( 344 <Button 345 label={l`Close`} 346 size="small" 347 shape={enableSquareButtons ? 'square' : 'round'} 348 variant={IS_WEB ? 'ghost' : 'solid'} 349 color="secondary" 350 style={[ 351 a.absolute, 352 a.z_20, 353 web({right: -4}), 354 native({right: 0}), 355 native({height: 32, width: 32, borderRadius: 16}), 356 ]} 357 onPress={() => control.close()}> 358 <ButtonIcon icon={X} size="md" /> 359 </Button> 360 ) : null} 361 </View> 362 363 <View style={web([a.pt_xs])}> 364 <SearchInput 365 inputRef={inputRef} 366 value={searchText} 367 onChangeText={text => { 368 setSearchText(text) 369 listRef.current?.scrollToOffset({offset: 0, animated: false}) 370 }} 371 onEscape={control.close} 372 /> 373 </View> 374 </View> 375 ) 376 }, [ 377 t.atoms.border_contrast_low, 378 t.atoms.bg, 379 t.atoms.text_contrast_high, 380 l, 381 title, 382 searchText, 383 control, 384 enableSquareButtons, 385 ]) 386 387 return ( 388 <Dialog.InnerFlatList 389 ref={listRef} 390 data={items} 391 renderItem={renderItems} 392 ListHeaderComponent={listHeader} 393 stickyHeaderIndices={[0]} 394 keyExtractor={(item: Item) => item.key} 395 style={[ 396 web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), 397 native({height: '100%'}), 398 ]} 399 webInnerContentContainerStyle={a.py_0} 400 webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} 401 scrollIndicatorInsets={{top: headerHeight}} 402 keyboardDismissMode="on-drag" 403 /> 404 ) 405} 406 407function DefaultProfileCard({ 408 profile, 409 moderationOpts, 410 onPress, 411}: { 412 profile: bsky.profile.AnyProfileView 413 moderationOpts: ModerationOpts 414 onPress: (did: string) => void 415}) { 416 const t = useTheme() 417 const {t: l} = useLingui() 418 const enabled = canBeMessaged(profile) 419 const moderation = moderateProfile(profile, moderationOpts) 420 const handle = sanitizeHandle(profile.handle, '@') 421 const displayName = createSanitizedDisplayName( 422 profile, 423 true, 424 moderation.ui('displayName'), 425 ) 426 427 const handleOnPress = useCallback(() => { 428 onPress(profile.did) 429 }, [onPress, profile.did]) 430 431 return ( 432 <Button 433 disabled={!enabled} 434 label={l`Start chat with ${displayName}`} 435 onPress={handleOnPress}> 436 {({hovered, pressed, focused}) => ( 437 <View 438 style={[ 439 a.flex_1, 440 a.py_sm, 441 a.px_lg, 442 !enabled 443 ? {opacity: 0.5} 444 : pressed || focused || hovered 445 ? t.atoms.bg_contrast_25 446 : t.atoms.bg, 447 ]}> 448 <ProfileCard.Header> 449 <ProfileCard.Avatar 450 profile={profile} 451 moderationOpts={moderationOpts} 452 disabledPreview 453 /> 454 <View style={[a.flex_1]}> 455 <ProfileCard.Name 456 profile={profile} 457 moderationOpts={moderationOpts} 458 /> 459 {enabled ? ( 460 <ProfileCard.Handle profile={profile} /> 461 ) : ( 462 <Text 463 style={[a.leading_snug, t.atoms.text_contrast_high]} 464 numberOfLines={2}> 465 <Trans>{handle} can't be messaged</Trans> 466 </Text> 467 )} 468 </View> 469 </ProfileCard.Header> 470 </View> 471 )} 472 </Button> 473 ) 474} 475 476function ExistingChatCard({ 477 convo, 478 moderationOpts, 479 onPress, 480}: { 481 convo: ConvoWithDetails 482 moderationOpts: ModerationOpts 483 onPress: (convoId: string) => void 484}) { 485 const t = useTheme() 486 const {t: l} = useLingui() 487 const enabled = 488 convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true 489 const moderation = moderateProfile(convo.primaryMember, moderationOpts) 490 const name = 491 convo.kind === 'group' 492 ? convo.details.name 493 : createSanitizedDisplayName( 494 convo.primaryMember, 495 true, 496 moderation.ui('displayName'), 497 ) 498 499 const handleOnPress = useCallback(() => { 500 onPress(convo.view.id) 501 }, [onPress, convo.view.id]) 502 503 return ( 504 <Button 505 disabled={!enabled} 506 label={l`Select chat "${name}"`} 507 onPress={handleOnPress}> 508 {({hovered, pressed, focused}) => ( 509 <View 510 style={[ 511 a.flex_1, 512 a.py_sm, 513 a.px_lg, 514 !enabled 515 ? {opacity: 0.5} 516 : pressed || focused || hovered 517 ? t.atoms.bg_contrast_25 518 : t.atoms.bg, 519 ]}> 520 <ProfileCard.Header> 521 {convo.kind === 'group' ? ( 522 <AvatarBubbles profiles={convo.members} size="small" /> 523 ) : ( 524 <ProfileCard.Avatar 525 profile={convo.primaryMember} 526 moderationOpts={moderationOpts} 527 disabledPreview 528 /> 529 )} 530 <View style={[a.flex_1]}> 531 <View style={[a.flex_row, a.align_center, a.max_w_full]}> 532 <Text 533 emoji 534 style={[ 535 a.text_md, 536 a.font_semi_bold, 537 a.leading_snug, 538 a.self_start, 539 a.flex_shrink, 540 ]} 541 numberOfLines={1}> 542 {name} 543 </Text> 544 {convo.kind === 'direct' && ( 545 <ProfileBadges 546 profile={convo.primaryMember} 547 size="md" 548 style={[a.pl_xs]} 549 /> 550 )} 551 </View> 552 {convo.kind === 'direct' ? ( 553 <ProfileCard.Handle profile={convo.primaryMember} /> 554 ) : ( 555 <> 556 {enabled ? ( 557 <Text 558 style={[a.leading_snug, t.atoms.text_contrast_medium]} 559 numberOfLines={2}> 560 <Plural 561 value={convo.members.length} 562 one="# member" 563 other="# members" 564 /> 565 </Text> 566 ) : ( 567 <Text 568 style={[a.leading_snug, t.atoms.text_contrast_high]} 569 numberOfLines={2}> 570 <Trans>Group is locked</Trans> 571 </Text> 572 )} 573 </> 574 )} 575 </View> 576 </ProfileCard.Header> 577 </View> 578 )} 579 </Button> 580 ) 581} 582 583function ProfileCardSkeleton() { 584 const t = useTheme() 585 const enableSquareButtons = useEnableSquareButtons() 586 587 return ( 588 <View 589 style={[ 590 a.flex_1, 591 a.py_md, 592 a.px_lg, 593 a.gap_md, 594 a.align_center, 595 a.flex_row, 596 ]}> 597 <View 598 style={[ 599 enableSquareButtons ? a.rounded_sm : a.rounded_full, 600 {width: 42, height: 42}, 601 t.atoms.bg_contrast_25, 602 ]} 603 /> 604 605 <View style={[a.flex_1, a.gap_sm]}> 606 <View 607 style={[ 608 a.rounded_xs, 609 {width: 80, height: 14}, 610 t.atoms.bg_contrast_25, 611 ]} 612 /> 613 <View 614 style={[ 615 a.rounded_xs, 616 {width: 120, height: 10}, 617 t.atoms.bg_contrast_25, 618 ]} 619 /> 620 </View> 621 </View> 622 ) 623} 624 625function Empty({message}: {message: string}) { 626 const t = useTheme() 627 return ( 628 <View style={[a.p_lg, a.py_xl, a.align_center, a.gap_md]}> 629 <Text style={[a.text_sm, a.italic, t.atoms.text_contrast_high]}> 630 {message} 631 </Text> 632 633 <Text style={[a.text_xs, t.atoms.text_contrast_low]}>(╯°□°)╯︵ ┻━┻</Text> 634 </View> 635 ) 636} 637 638function SearchInput({ 639 value, 640 onChangeText, 641 onEscape, 642 inputRef, 643}: { 644 value: string 645 onChangeText: (text: string) => void 646 onEscape: () => void 647 inputRef: React.RefObject<TextInput | null> 648}) { 649 const t = useTheme() 650 const {t: l} = useLingui() 651 const { 652 state: hovered, 653 onIn: onMouseEnter, 654 onOut: onMouseLeave, 655 } = useInteractionState() 656 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() 657 const interacted = hovered || focused 658 659 return ( 660 <View 661 {...web({ 662 onMouseEnter, 663 onMouseLeave, 664 })} 665 style={[a.flex_row, a.align_center, a.gap_sm]}> 666 <Search 667 size="md" 668 fill={interacted ? t.palette.primary_500 : t.palette.contrast_300} 669 /> 670 671 <TextInput 672 // @ts-ignore bottom sheet input types issue — esb 673 ref={inputRef} 674 placeholder={l`Search`} 675 value={value} 676 onChangeText={onChangeText} 677 onFocus={onFocus} 678 onBlur={onBlur} 679 style={[a.flex_1, a.py_md, a.text_md, t.atoms.text]} 680 placeholderTextColor={t.palette.contrast_500} 681 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'} 682 returnKeyType="search" 683 clearButtonMode="while-editing" 684 maxLength={50} 685 onKeyPress={({nativeEvent}) => { 686 if (nativeEvent.key === 'Escape') { 687 onEscape() 688 } 689 }} 690 autoCorrect={false} 691 autoComplete="off" 692 autoCapitalize="none" 693 autoFocus 694 accessibilityLabel={l`Search profiles`} 695 accessibilityHint={l`Searches for profiles`} 696 /> 697 </View> 698 ) 699}