import { useCallback, useLayoutEffect, useMemo, useReducer, useRef, useState, } from 'react' import {LayoutAnimation, type TextInput, View} from 'react-native' import {Trans, useLingui} from '@lingui/react/macro' import {useModerationOpts} from '#/state/preferences/moderation-opts' import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete' import {useListConvoMembersQuery} from '#/state/queries/messages/list-convo-members' 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, ButtonText} from '#/components/Button' import * as Dialog from '#/components/Dialog' import {canBeAddedToGroup, type ConvoWithDetails} from '#/components/dms/util' import * as Toggle from '#/components/forms/Toggle' import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon} from '#/components/icons/Arrow' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import {Loader} from '#/components/Loader' import {Text} from '#/components/Typography' import {IS_NATIVE, IS_WEB} from '#/env' import type * as bsky from '#/types/bsky' import {ChatProfileTabs} from './ChatProfileTabs' import {EmptyMemberList} from './components/EmptyMemberList' import {GroupChatProfileCard} from './components/GroupChatProfileCard' import {ProfileCardSkeleton} from './components/ProfileCardSkeleton' import {UserLabel} from './components/UserLabel' import {UserSearchInput} from './components/UserSearchInput' type LabelItem = { type: 'label' key: string message: string } 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 LoadingItem = { type: 'loading' key: string } type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | LoadingItem export type State = { groupChatDids: string[] groupChatProfiles: bsky.profile.AnyProfileView[] searchText: string } export type Action = | { type: 'setDids' groupChatDids: string[] groupChatProfiles: bsky.profile.AnyProfileView[] } | { type: 'removeDids' groupChatDids: string[] groupChatProfiles: bsky.profile.AnyProfileView[] } | { type: 'setSearchText' searchText: string } function reducer(state: State, action: Action): State { switch (action.type) { case 'setDids': { return { ...state, groupChatDids: action.groupChatDids, groupChatProfiles: action.groupChatProfiles, searchText: '', } } case 'removeDids': { return { ...state, groupChatDids: action.groupChatDids, groupChatProfiles: action.groupChatProfiles, } } case 'setSearchText': { return { ...state, searchText: action.searchText, } } } } export function AddMembersFlow({ convo, title, onAddMembers, }: { convo: Extract title: string onAddMembers: ( dids: string[], profiles: bsky.profile.AnyProfileView[], ) => void }) { const t = useTheme() const {t: l} = useLingui() const moderationOpts = useModerationOpts() const {currentAccount} = useSession() const control = Dialog.useDialogContext() const [headerHeight, setHeaderHeight] = useState(0) const [footerHeight, setFooterHeight] = useState(0) const listRef = useRef(null) const inputRef = useRef(null) const [{groupChatDids, groupChatProfiles, searchText}, dispatch] = useReducer( reducer, { groupChatDids: [], groupChatProfiles: [], searchText: '', }, ) const { data: autocompleteResults, isError, isFetching: isAutocompleteFetching, } = useActorAutocompleteQuery(searchText, true, 12) const {data: follows} = useProfileFollowsQuery(currentAccount?.did) const {data: memberListData = [], isPending: isMemberListPending} = useListConvoMembersQuery({ convoId: convo.view.id, placeholderData: convo.members, }) const memberDidSet = useMemo( () => new Set(memberListData.map(profile => profile.did)), [memberListData], ) // The existing members (including the viewer) already occupy slots, so the // number of people that can still be added is whatever's left. const remainingSlots = Math.max( 0, convo.details.memberLimit - memberListData.length, ) const onRemoveDid = useCallback( (did: string) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) dispatch({ type: 'removeDids', groupChatDids: groupChatDids.filter(d => d !== did), groupChatProfiles: groupChatProfiles.filter( profile => profile.did !== did, ), }) }, [groupChatDids, groupChatProfiles], ) const items = useMemo(() => { if (isMemberListPending) { // Still fetching chat member DIDs for filtering, so force the loading state. return [] } const _items: Item[] = [] if (isError) { _items.push({ type: 'empty', key: 'empty', message: l`We’re having network issues, try again`, }) } else if (searchText.length) { if (autocompleteResults?.length) { for (const profile of autocompleteResults) { if ( profile.did === currentAccount?.did || memberDidSet.has(profile.did) ) continue _items.push({ type: 'profile', key: profile.did, profile, }) } _items.sort(item => { return item.type === 'profile' && canBeAddedToGroup(item.profile) ? -1 : 1 }) } } else { if (follows) { for (const page of follows.pages) { for (const profile of page.follows) { if (!canBeAddedToGroup(profile)) continue _items.push({ type: 'profile', key: profile.did, profile, }) } } } else { for (let i = 0; i < 10; i++) { _items.push({type: 'placeholder', key: i + ''}) } } } if (searchText === '' && _items.length > 0) { _items.unshift({ type: 'label', key: 'suggested', message: l`Suggested`, }) } if (searchText && isAutocompleteFetching && _items.length > 0) { // Stale results are still showing while autocomplete refetches - // append an inline indicator so the user sees that work is happening. _items.push({type: 'loading', key: 'loading'}) } else if ( searchText && !isAutocompleteFetching && !_items.length && !isError ) { _items.push({type: 'empty', key: 'empty', message: l`No results`}) } return _items }, [ autocompleteResults, currentAccount?.did, follows, isAutocompleteFetching, isError, isMemberListPending, l, memberDidSet, searchText, ]) const handlePressBack = useCallback(() => { control.close() }, [control]) const handlePressAdd = useCallback(() => { onAddMembers(groupChatDids, groupChatProfiles) }, [groupChatDids, groupChatProfiles, onAddMembers]) const renderItems = useCallback( ({item}: {item: Item}) => { switch (item.type) { case 'label': { return } case 'profile': { return ( ) } case 'placeholder': { return } case 'loading': { return ( ) } case 'empty': { return } default: return null } }, [moderationOpts], ) useLayoutEffect(() => { if (IS_WEB) { setImmediate(() => { inputRef?.current?.focus() }) } }, []) let buttonLabel = l`Continue to group name` let buttonText = l`Next` let showButton = groupChatProfiles.length > 0 let isButtonDisabled = !showButton const showChatProfileTabs = groupChatProfiles.length > 0 const listHeader = useMemo( () => ( setHeaderHeight(evt.nativeEvent.layout.height)}> {IS_NATIVE ? ( ) : null} {title} {IS_WEB ? ( ) : showButton ? ( ) : null} { dispatch({type: 'setSearchText', searchText: text}) listRef.current?.scrollToOffset({offset: 0, animated: false}) }} onEscape={control.close} /> {showChatProfileTabs ? ( ) : null} ), [ buttonLabel, control, groupChatProfiles, handlePressAdd, handlePressBack, isButtonDisabled, l, onRemoveDid, searchText, showButton, showChatProfileTabs, t.atoms.bg, t.atoms.border_contrast_low, t.atoms.text_contrast_high, title, ], ) const setGroupChatMembers = (dids: string[]) => { LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) const added = dids.filter(d => !groupChatDids.includes(d)) const removed = groupChatDids.filter(d => !dids.includes(d)) const newDids = [ ...groupChatDids.filter(d => !removed.includes(d)), ...added, ] const kept = groupChatProfiles.filter(p => dids.includes(p.did)) const keptDids = new Set(kept.map(p => p.did)) const addedProfiles = items .filter( (item): item is ProfileItem => item.type === 'profile' && dids.includes(item.profile.did) && !keptDids.has(item.profile.did), ) .map(item => item.profile) .sort((a, b) => dids.indexOf(a.did) - dids.indexOf(b.did)) dispatch({ type: 'setDids', groupChatDids: newDids, groupChatProfiles: [...kept, ...addedProfiles], }) } return ( ) : null } keyExtractor={(item: Item) => item.key} style={[ web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), native({height: '100%'}), ]} contentContainerStyle={items.length === 0 ? {flexGrow: 1} : undefined} webInnerContentContainerStyle={[ a.py_0, {paddingBottom: footerHeight}, items.length === 0 && {flexGrow: 1}, ]} webInnerStyle={[a.py_0, {maxWidth: 500, minWidth: 200}]} scrollIndicatorInsets={{top: headerHeight, bottom: footerHeight}} keyboardDismissMode="on-drag" footer={ IS_WEB ? ( setFooterHeight(evt.nativeEvent.layout.height)}> ) : null } /> ) }