import { Fragment, useCallback, useLayoutEffect, useMemo, useRef, useState, } from 'react' import {TextInput, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' import {Plural, Trans, useLingui} from '@lingui/react/macro' import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name' import {sanitizeHandle} from '#/lib/strings/handles' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useListConvosQuery} from '#/state/queries/messages/list-conversations' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useSession} from '#/state/session' import {type ListMethods} from '#/view/com/util/List' import {android, atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon} from '#/components/Button' import * as Dialog from '#/components/Dialog' import { canBeMessaged, type ConvoWithDetails, parseConvoView, } from '#/components/dms/util' import {useInteractionState} from '#/components/hooks/useInteractionState' import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass' import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import * as ProfileCard from '#/components/ProfileCard' import {Text} from '#/components/Typography' import {IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' import {AvatarBubbles} from '../AvatarBubbles' import {Error} from '../Error' import {ProfileBadges} from '../ProfileBadges' export type ProfileItem = { type: 'profile' key: string profile: bsky.profile.AnyProfileView } type ExistingChatItem = { type: 'existingChat' key: string convo: ConvoWithDetails } type EmptyItem = { type: 'empty' key: string message: string } type PlaceholderItem = { type: 'placeholder' key: string } type ErrorItem = { type: 'error' key: string } type Item = | ProfileItem | ExistingChatItem | EmptyItem | PlaceholderItem | ErrorItem export function SearchablePeopleList({ title, showRecentConvos, sortByMessageDeclaration, onSelectChat, renderProfileCard, }: { title: string showRecentConvos?: boolean sortByMessageDeclaration?: boolean } & ( | { renderProfileCard: (item: ProfileItem) => React.ReactNode onSelectChat?: undefined } | { onSelectChat: ( chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string}, ) => void renderProfileCard?: undefined } )) { const t = useTheme() const {t: l} = useLingui() const moderationOpts = useModerationOpts() const control = Dialog.useDialogContext() const [headerHeight, setHeaderHeight] = useState(0) const listRef = useRef(null) const {currentAccount} = useSession() const inputRef = useRef(null) const [searchText, setSearchText] = useState('') const enableSquareButtons = useEnableSquareButtons() const { data: results, isError, isFetching, } = useActorAutocompleteQuery(searchText, true, 12) const {data: follows} = useProfileFollowsQuery(currentAccount?.did) const {data: convos} = useListConvosQuery({ enabled: showRecentConvos, status: 'accepted', }) const items = useMemo(() => { let _items: Item[] = [] if (isError) { _items.push({ type: 'empty', key: 'empty', message: l`We're having network issues, try again`, }) } else if (searchText.length) { if (results?.length) { for (const profile of results) { if (profile.did === currentAccount?.did) continue _items.push({ type: 'profile', key: profile.did, profile, }) } if (sortByMessageDeclaration) { _items = _items.sort(item => { return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 }) } } } else { const placeholders: Item[] = Array(10) .fill(0) .map((__, i) => ({ type: 'placeholder', key: i + '', })) if (showRecentConvos) { if (convos && follows) { const usedDids = new Set() for (const page of convos.pages) { for (const convoView of page.convos) { const convo = parseConvoView(convoView, currentAccount?.did) if (!convo) continue if (convo.kind === 'group') { _items.push({ type: 'existingChat', key: convo.view.id, convo, }) } else { if (convo.primaryMember.handle === 'missing.invalid') continue if (usedDids.has(convo.primaryMember.did)) continue usedDids.add(convo.primaryMember.did) _items.push({ type: 'existingChat', key: convo.view.id, convo: convo, }) } } } let followsItems: ProfileItem[] = [] for (const page of follows.pages) { for (const profile of page.follows) { if (usedDids.has(profile.did)) continue followsItems.push({ type: 'profile', key: profile.did, profile, }) } } if (sortByMessageDeclaration) { // only sort follows followsItems = followsItems.sort(item => { return canBeMessaged(item.profile) ? -1 : 1 }) } // then append _items.push(...followsItems) } else { _items.push(...placeholders) } } else if (follows) { for (const page of follows.pages) { for (const profile of page.follows) { _items.push({ type: 'profile', key: profile.did, profile, }) } } if (sortByMessageDeclaration) { _items = _items.sort(item => { return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 }) } } else { _items.push(...placeholders) } } return _items }, [ l, searchText, results, isError, currentAccount?.did, follows, convos, showRecentConvos, sortByMessageDeclaration, ]) if (searchText && !isFetching && !items.length && !isError) { items.push({type: 'empty', key: 'empty', message: l`No results`}) } const renderItems = useCallback( ({item}: {item: Item}) => { switch (item.type) { case 'existingChat': { if (renderProfileCard) { // should be unreachable return null } else { return ( onSelectChat({kind: 'convo', id})} /> ) } } case 'profile': { if (renderProfileCard) { return {renderProfileCard(item)} } else { return ( onSelectChat({kind: 'user', did})} /> ) } } case 'placeholder': { return } case 'empty': { return } case 'error': { return } default: return null } }, [moderationOpts, onSelectChat, renderProfileCard, l], ) useLayoutEffect(() => { if (IS_WEB) { setImmediate(() => { inputRef?.current?.focus() }) } }, []) const listHeader = useMemo(() => { return ( setHeaderHeight(evt.nativeEvent.layout.height)} style={[ a.relative, web(a.pt_lg), native(a.pt_4xl), android({ borderTopLeftRadius: a.rounded_md.borderRadius, borderTopRightRadius: a.rounded_md.borderRadius, }), a.pb_xs, a.px_lg, a.border_b, t.atoms.border_contrast_low, t.atoms.bg, ]}> {title} {IS_WEB ? ( ) : null} { setSearchText(text) listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onEscape={control.close} /> ) }, [ t.atoms.border_contrast_low, t.atoms.bg, t.atoms.text_contrast_high, l, title, searchText, control, enableSquareButtons, ]) return ( item.key} style={[ web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), native({height: '100%'}), ]} webInnerContentContainerStyle={a.py_0} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} scrollIndicatorInsets={{top: headerHeight}} keyboardDismissMode="on-drag" /> ) } function DefaultProfileCard({ profile, moderationOpts, onPress, }: { profile: bsky.profile.AnyProfileView moderationOpts: ModerationOpts onPress: (did: string) => void }) { const t = useTheme() const {t: l} = useLingui() const enabled = canBeMessaged(profile) const moderation = moderateProfile(profile, moderationOpts) const handle = sanitizeHandle(profile.handle, '@') const displayName = createSanitizedDisplayName( profile, true, moderation.ui('displayName'), ) const handleOnPress = useCallback(() => { onPress(profile.did) }, [onPress, profile.did]) return ( ) } function ExistingChatCard({ convo, moderationOpts, onPress, }: { convo: ConvoWithDetails moderationOpts: ModerationOpts onPress: (convoId: string) => void }) { const t = useTheme() const {t: l} = useLingui() const enabled = convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true const moderation = moderateProfile(convo.primaryMember, moderationOpts) const name = convo.kind === 'group' ? convo.details.name : createSanitizedDisplayName( convo.primaryMember, true, moderation.ui('displayName'), ) const handleOnPress = useCallback(() => { onPress(convo.view.id) }, [onPress, convo.view.id]) return ( ) } function ProfileCardSkeleton() { const t = useTheme() const enableSquareButtons = useEnableSquareButtons() return ( ) } function Empty({message}: {message: string}) { const t = useTheme() return ( {message} (╯°□°)╯︵ ┻━┻ ) } function SearchInput({ value, onChangeText, onEscape, inputRef, }: { value: string onChangeText: (text: string) => void onEscape: () => void inputRef: React.RefObject }) { const t = useTheme() const {t: l} = useLingui() const { state: hovered, onIn: onMouseEnter, onOut: onMouseLeave, } = useInteractionState() const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() const interacted = hovered || focused return ( { if (nativeEvent.key === 'Escape') { onEscape() } }} autoCorrect={false} autoComplete="off" autoCapitalize="none" autoFocus accessibilityLabel={l`Search profiles`} accessibilityHint={l`Searches for profiles`} /> ) }