From 719d7b7a57c96663292d886adb6f19e283e309e0 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Thu, 17 Apr 2025 19:11:46 +0300 Subject: Use `SearchablePeopleList` for add user to list dialog, replace old modal (#8212) * move to dialogs dir * make searchable people list more generic * new list-add-remove-users dialog * update header text * fix header on android * delete old modal * reduce spacing on items --- src/components/dialogs/SearchablePeopleList.tsx | 538 ++++++++++++++++++++++++ 1 file changed, 538 insertions(+) create mode 100644 src/components/dialogs/SearchablePeopleList.tsx (limited to 'src/components/dialogs/SearchablePeopleList.tsx') diff --git a/src/components/dialogs/SearchablePeopleList.tsx b/src/components/dialogs/SearchablePeopleList.tsx new file mode 100644 index 000000000..26e20db57 --- /dev/null +++ b/src/components/dialogs/SearchablePeopleList.tsx @@ -0,0 +1,538 @@ +import { + Fragment, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react' +import {TextInput, View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {isWeb} from '#/platform/detection' +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} from '#/components/dms/util' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {MagnifyingGlass2_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass2' +import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +export type ProfileItem = { + type: 'profile' + key: string + profile: bsky.profile.AnyProfileView +} + +type EmptyItem = { + type: 'empty' + key: string + message: string +} + +type PlaceholderItem = { + type: 'placeholder' + key: string +} + +type ErrorItem = { + type: 'error' + key: string +} + +type Item = ProfileItem | 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: (did: string) => void + renderProfileCard?: undefined + } +)) { + const t = useTheme() + const {_} = 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 { + 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: _(msg`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 convo of page.convos) { + const profiles = convo.members.filter( + m => m.did !== currentAccount?.did, + ) + + for (const profile of profiles) { + if (usedDids.has(profile.did)) continue + + usedDids.add(profile.did) + + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + } + + 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 + }, [ + _, + searchText, + results, + isError, + currentAccount?.did, + follows, + convos, + showRecentConvos, + sortByMessageDeclaration, + ]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: _(msg`No results`)}) + } + + const renderItems = useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'profile': { + if (renderProfileCard) { + return {renderProfileCard(item)} + } else { + return ( + + ) + } + } + case 'placeholder': { + return + } + case 'empty': { + return + } + default: + return null + } + }, + [moderationOpts, onSelectChat, renderProfileCard], + ) + + useLayoutEffect(() => { + if (isWeb) { + 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} + + {isWeb ? ( + + ) : 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, + _, + title, + searchText, + control, + ]) + + 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 {_} = useLingui() + const enabled = canBeMessaged(profile) + const moderation = moderateProfile(profile, moderationOpts) + const handle = sanitizeHandle(profile.handle, '@') + const displayName = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + + const handleOnPress = useCallback(() => { + onPress(profile.did) + }, [onPress, profile.did]) + + return ( + + ) +} + +function ProfileCardSkeleton() { + const t = useTheme() + + 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 {_} = 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={_(msg`Search profiles`)} + accessibilityHint={_(msg`Searches for profiles`)} + /> + + ) +} -- cgit 1.4.1