From f4e14626aab6b95a89af86a0cade00b9d6bbf505 Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Thu, 26 Mar 2026 10:23:49 -0700 Subject: [PATCH] Add button for creating a new group clip clop (#10066) Co-authored-by: Samuel Newman --- src/analytics/features/types.ts | 1 + src/components/dms/ChatProfileTabs.tsx | 169 +++ src/components/dms/InitiateChatFlow.tsx | 1007 ++++++++++++++++++ src/components/dms/dialogs/NewChatDialog.tsx | 39 +- src/components/forms/TextField.tsx | 4 +- 5 files changed, 1206 insertions(+), 14 deletions(-) create mode 100644 src/components/dms/ChatProfileTabs.tsx create mode 100644 src/components/dms/InitiateChatFlow.tsx diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index d851b00ead..44eebf561e 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -10,6 +10,7 @@ export enum Features { ImportContactsSettingsDisable = 'import_contacts:settings:disable', LiveNowBetaDisable = 'live_now_beta:disable', ImageUploadsHighResolution = 'image_uploads:high_resolution', + GroupChatsEnable = 'group_chats:enable', AATest = 'aa-test', } diff --git a/src/components/dms/ChatProfileTabs.tsx b/src/components/dms/ChatProfileTabs.tsx new file mode 100644 index 0000000000..bac128c819 --- /dev/null +++ b/src/components/dms/ChatProfileTabs.tsx @@ -0,0 +1,169 @@ +import {useCallback, useEffect} from 'react' +import {type ScrollView, View} from 'react-native' +import Animated, {useAnimatedRef, useSharedValue} from 'react-native-reanimated' +import {moderateProfile} from '@atproto/api' +import {useLingui} from '@lingui/react/macro' + +import {HITSLOP_10} from '#/lib/constants' +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {TimesLarge_Stroke2_Corner0_Rounded as XIcon} from '#/components/icons/Times' +import * as ProfileCard from '#/components/ProfileCard' +import {Text} from '#/components/Typography' +import type * as bsky from '#/types/bsky' + +type Props = { + testID?: string + profiles: bsky.profile.AnyProfileView[] + onRemove?: (did: string) => void +} + +export function ChatProfileTabs({testID, profiles, onRemove}: Props) { + const t = useTheme() + const scrollElRef = useAnimatedRef() + const contentSize = useSharedValue(0) + const scrollX = useSharedValue(0) + + useEffect(() => { + requestAnimationFrame(() => { + // Scroll to the end of the list when `profiles` changes. + scrollElRef.current?.scrollToEnd({animated: true}) + }) + }, [profiles, scrollElRef]) + + return ( + + { + scrollX.set(Math.round(e.nativeEvent.contentOffset.x)) + }}> + { + contentSize.set(e.nativeEvent.layout.width) + }}> + {profiles.map((profile, index) => ( + + ))} + + + + ) +} + +function Tab({ + testID, + index, + profile, + total, + onRemove, +}: { + testID?: string + index: number + profile: bsky.profile.AnyProfileView + total: number + onRemove?: (did: string) => void +}) { + const t = useTheme() + const {t: l} = useLingui() + const moderationOpts = useModerationOpts() + + const moderation = moderateProfile(profile, moderationOpts!) + const displayName = sanitizeDisplayName( + profile.displayName || sanitizeHandle(profile.handle), + moderation.ui('displayName'), + ) + + const onPressItem = useCallback( + (did: string) => { + onRemove?.(did) + }, + [onRemove], + ) + + return ( + + {moderationOpts ? ( + <> + + + + {displayName} + + + + ) : ( + <> + + + + )} + + + ) +} diff --git a/src/components/dms/InitiateChatFlow.tsx b/src/components/dms/InitiateChatFlow.tsx new file mode 100644 index 0000000000..4b171d964f --- /dev/null +++ b/src/components/dms/InitiateChatFlow.tsx @@ -0,0 +1,1007 @@ +import { + useCallback, + useLayoutEffect, + useMemo, + useReducer, + useRef, + useState, +} from 'react' +import {LayoutAnimation, TextInput, View} from 'react-native' +import {moderateProfile, type ModerationOpts} from '@atproto/api' +import {Trans, useLingui} from '@lingui/react/macro' + +import {sanitizeDisplayName} from '#/lib/strings/display-names' +import {sanitizeHandle} from '#/lib/strings/handles' +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 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' +import {Text} from '#/components/Typography' +import {IS_NATIVE, IS_WEB} from '#/env' +import type * as bsky from '#/types/bsky' +import {ChatProfileTabs} from './ChatProfileTabs' + +type NewGroupChatItem = { + type: 'newGroupChat' + key: string +} + +type LabelItem = { + type: 'label' + key: string + message: string +} + +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 = + | NewGroupChatItem + | LabelItem + | ProfileItem + | EmptyItem + | PlaceholderItem + | ErrorItem + +enum ChatState { + NEW_CHAT, + NEW_GROUP_CHAT, + GROUP_NAME, +} + +export type State = { + chatState: ChatState + screenTitle: string + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + groupName: string +} + +export type Action = + | { + type: 'startNewGroupChat' + screenTitle: string + } + | { + type: 'setDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + | { + type: 'removeDids' + groupChatDids: string[] + groupChatProfiles: bsky.profile.AnyProfileView[] + } + | { + type: 'startNameGroup' + screenTitle: string + } + | { + type: 'nameGroup' + groupName: string + } + | { + type: 'goBackFromNewGroupChat' + screenTitle: string + } + | { + type: 'goBackFromGroupName' + screenTitle: string + } + +function reducer(state: State, action: Action): State { + switch (action.type) { + case 'startNewGroupChat': { + return { + ...state, + chatState: ChatState.NEW_GROUP_CHAT, + screenTitle: action.screenTitle, + groupChatDids: [], + groupChatProfiles: [], + groupName: '', + } + } + case 'setDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + case 'removeDids': { + return { + ...state, + groupChatDids: action.groupChatDids, + groupChatProfiles: action.groupChatProfiles, + } + } + case 'startNameGroup': { + return { + ...state, + chatState: ChatState.GROUP_NAME, + screenTitle: action.screenTitle, + } + } + case 'nameGroup': { + return { + ...state, + groupName: action.groupName, + } + } + case 'goBackFromNewGroupChat': { + return { + ...state, + chatState: ChatState.NEW_CHAT, + screenTitle: action.screenTitle, + groupChatDids: [], + groupChatProfiles: [], + groupName: '', + } + } + case 'goBackFromGroupName': { + return { + ...state, + chatState: ChatState.NEW_GROUP_CHAT, + screenTitle: action.screenTitle, + groupName: '', + } + } + } +} +export function InitiateChatFlow({ + title, + onSelectChat, + onSelectGroupChat, +}: { + title: string + onSelectChat: (did: string) => void + onSelectGroupChat: (dids: string[], groupName: 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 [ + {chatState, screenTitle, groupChatDids, groupChatProfiles, groupName}, + dispatch, + ] = useReducer(reducer, { + chatState: ChatState.NEW_CHAT, + screenTitle: title, + groupChatDids: [], + groupChatProfiles: [], + groupName: '', + }) + + const newGroupChatTitle = l`New group chat` + const groupNameTitle = l`Group name` + + 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 (chatState === ChatState.GROUP_NAME) { + _items = groupChatProfiles.map(profile => ({ + type: 'profile', + key: profile.did, + profile, + })) + _items.unshift({ + type: 'label', + key: 'members', + message: l`New group chat with:`, + }) + } 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 === '' && + (chatState === ChatState.NEW_CHAT || + chatState === ChatState.NEW_GROUP_CHAT) + ) { + _items.unshift({ + type: 'label', + key: 'suggested', + message: l`Suggested`, + }) + } + + if (chatState === ChatState.NEW_CHAT && searchText === '') { + _items.unshift({type: 'newGroupChat', key: 'newGroupChat'}) + } + + return _items + }, [ + isError, + chatState, + searchText, + l, + groupChatProfiles, + results, + currentAccount?.did, + follows, + ]) + + if (searchText && !isFetching && !items.length && !isError) { + items.push({type: 'empty', key: 'empty', message: l`No results`}) + } + + const handlePressBack = useCallback(() => { + switch (chatState) { + case ChatState.NEW_CHAT: + control.close() + break + case ChatState.NEW_GROUP_CHAT: + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + dispatch({type: 'goBackFromNewGroupChat', screenTitle: title}) + setSearchText('') + break + case ChatState.GROUP_NAME: + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + dispatch({type: 'goBackFromGroupName', screenTitle: newGroupChatTitle}) + break + } + }, [chatState, control, newGroupChatTitle, title]) + + const handlePressNewGroupChat = useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + dispatch({type: 'startNewGroupChat', screenTitle: newGroupChatTitle}) + }, [newGroupChatTitle]) + + const handlePressNext = useCallback(() => { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) + dispatch({type: 'startNameGroup', screenTitle: groupNameTitle}) + setSearchText('') + }, [groupNameTitle]) + + const handlePressConfirm = useCallback(() => { + onSelectGroupChat(groupChatDids, groupName) + }, [groupChatDids, groupName, onSelectGroupChat]) + + const setGroupName = (newGroupName: string) => { + dispatch({type: 'nameGroup', groupName: newGroupName}) + } + + const renderItems = useCallback( + ({item}: {item: Item}) => { + switch (item.type) { + case 'newGroupChat': { + return ( + + ) + } + case 'label': { + return