From 6e3c9c3a9f293993d681fa846f132a8dc694099f Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:55:19 -0700 Subject: [PATCH] Add flow for adding people to a group clip clop (#10255) --- src/components/dms/AddMembersFlow.tsx | 470 ++++++++++++++++++ src/components/dms/InitiateChatFlow.tsx | 174 +------ .../dms/components/EmptyMemberList.tsx | 16 + .../dms/components/GroupChatProfileCard.tsx | 65 +++ .../dms/components/ProfileCardSkeleton.tsx | 21 + src/components/dms/components/UserLabel.tsx | 15 + .../dms/components/UserSearchInput.tsx | 68 +++ src/screens/Messages/ConversationSettings.tsx | 158 +++--- .../components/MessagesListInfoPanel.tsx | 124 +++-- 9 files changed, 849 insertions(+), 262 deletions(-) create mode 100644 src/components/dms/AddMembersFlow.tsx create mode 100644 src/components/dms/components/EmptyMemberList.tsx create mode 100644 src/components/dms/components/GroupChatProfileCard.tsx create mode 100644 src/components/dms/components/ProfileCardSkeleton.tsx create mode 100644 src/components/dms/components/UserLabel.tsx create mode 100644 src/components/dms/components/UserSearchInput.tsx diff --git a/src/components/dms/AddMembersFlow.tsx b/src/components/dms/AddMembersFlow.tsx new file mode 100644 index 0000000000..103ad0aca4 --- /dev/null +++ b/src/components/dms/AddMembersFlow.tsx @@ -0,0 +1,470 @@ +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 {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 {canBeMessaged} 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 {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 ErrorItem = { + type: 'error' + key: string +} + +type Item = LabelItem | ProfileItem | EmptyItem | PlaceholderItem | ErrorItem + +export type State = { + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] +} + +export type Action = + | { + type: 'setDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + | { + type: 'removeDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'setDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + case 'removeDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + } +} + +export function AddMembersFlow({ + title, + onAddMembers, +}: { + title: string + onAddMembers: (dids: string[]) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const moderationOpts = useModerationOpts() + const control = Dialog.useDialogContext() + const [headerHeight, setHeaderHeight] = useState(0) + const [footerHeight, setFooterHeight] = 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 [{groupChatDids, groupChatProfiles}, dispatch] = useReducer(reducer, { + groupChatDids: [], + groupChatProfiles: [], + }) + + 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(() => { + 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, + }) + } + + _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 (follows) { + for (const page of follows.pages) { + for (const profile of page.follows) { + _items.push({ + type: 'profile', + key: profile.did, + profile, + }) + } + } + + _items = _items.sort(item => { + return item.type === 'profile' && canBeMessaged(item.profile) ? -1 : 1 + }) + } else { + _items.push(...placeholders) + } + } + + if (searchText === '') { + _items.unshift({ + type: 'label', + key: 'suggested', + message: l`Suggested`, + }) + } + + return _items + }, [isError, searchText, l, results, currentAccount?.did, follows]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: l`No results`}) + } + + const handlePressBack = useCallback(() => { + control.close() + }, [control]) + + const handlePressAdd = useCallback(() => { + onAddMembers(groupChatDids) + }, [groupChatDids, onAddMembers]) + + const renderItems = useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'label': { + return + } + case 'profile': { + return ( + + ) + } + case 'placeholder': { + 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} + + + { + setSearchText(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 ( + + item.key} + style={[ + web([a.py_0, {height: '100vh', maxHeight: 600}, a.px_0]), + native({height: '100%'}), + ]} + webInnerContentContainerStyle={[a.py_0, {paddingBottom: footerHeight}]} + 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 + } + /> + + ) +} diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx index 4b171d964f..bc4824f47c 100644 --- a/src/components/dms/InitiateChatFlow.tsx +++ b/src/components/dms/InitiateChatFlow.tsx @@ -6,7 +6,7 @@ import { useRef, useState, } from 'react' -import {LayoutAnimation, TextInput, View} from 'react-native' +import {LayoutAnimation, type TextInput, View} from 'react-native' import {moderateProfile, type ModerationOpts} from '@atproto/api' import {Trans, useLingui} from '@lingui/react/macro' @@ -23,13 +23,11 @@ import * as Dialog from '#/components/Dialog' import {canBeMessaged} from '#/components/dms/util' import * as TextField from '#/components/forms/TextField' import * as Toggle from '#/components/forms/Toggle' -import {useInteractionState} from '#/components/hooks/useInteractionState' import { ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeftIcon, ArrowRight_Stroke2_Corner0_Rounded as ArrowRightIcon, } from '#/components/icons/Arrow' import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRightIcon} from '#/components/icons/Chevron' -import {MagnifyingGlass_Stroke2_Corner0_Rounded as SearchIcon} from '#/components/icons/MagnifyingGlass' import {PersonGroup_Stroke2_Corner2_Rounded as PersonGroupIcon} from '#/components/icons/Person' import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' import * as ProfileCard from '#/components/ProfileCard' @@ -37,6 +35,11 @@ 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 NewGroupChatItem = { type: 'newGroupChat' @@ -49,7 +52,7 @@ type LabelItem = { message: string } -export type ProfileItem = { +type ProfileItem = { type: 'profile' key: string profile: bsky.profile.AnyProfileView @@ -184,6 +187,7 @@ function reducer(state: State, action: Action): State { } } } + export function InitiateChatFlow({ title, onSelectChat, @@ -382,7 +386,7 @@ export function InitiateChatFlow({ ) } case 'label': { - return