Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

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