Files
bsky-social-app/src/components/dms/InitiateChatFlow.tsx
T
2026-06-09 11:36:08 -07:00

933 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
useCallback,
useLayoutEffect,
useMemo,
useReducer,
useRef,
useState,
} from 'react'
import {LayoutAnimation, type TextInput, View} from 'react-native'
import {moderateProfile, type ModerationOpts} from '@atproto/api'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {isOverMaxGraphemeCount} from '#/lib/strings/helpers'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
import {useChatActorStatusQuery} from '#/state/queries/messages/get-status'
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, canBeMessaged} from '#/components/dms/util'
import * as TextField from '#/components/forms/TextField'
import * as Toggle from '#/components/forms/Toggle'
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 {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 * as Prompt from '#/components/Prompt'
import {Text} from '#/components/Typography'
import {useAgeAssurance} from '#/ageAssurance'
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'
key: string
}
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 =
| 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<ListMethods>(null)
const {currentAccount} = useSession()
const aa = useAgeAssurance()
const inputRef = useRef<TextInput>(null)
const accountTooNewPromptControl = Dialog.useDialogControl()
const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit
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[] = []
const checker =
chatState === ChatState.NEW_GROUP_CHAT ? canBeAddedToGroup : canBeMessaged
if (isError) {
_items.push({
type: 'empty',
key: 'empty',
message: l`Were 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' && checker(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) {
if (!checker(profile)) continue
_items.push({
type: 'profile',
key: profile.did,
profile,
})
}
}
} else {
_items.push(...placeholders)
}
}
if (
searchText === '' &&
_items.length > 0 &&
(chatState === ChatState.NEW_CHAT ||
chatState === ChatState.NEW_GROUP_CHAT)
) {
_items.unshift({
type: 'label',
key: 'suggested',
message: l`Suggested`,
})
}
if (
chatState === ChatState.NEW_CHAT &&
searchText === '' &&
!aa.flags.groupChatDisabled
) {
_items.unshift({type: 'newGroupChat', key: 'newGroupChat'})
}
return _items
}, [
isError,
chatState,
searchText,
l,
groupChatProfiles,
results,
currentAccount?.did,
follows,
aa.flags.groupChatDisabled,
])
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(() => {
if (!canCreateGroups) {
accountTooNewPromptControl.open()
return
}
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
dispatch({type: 'startNewGroupChat', screenTitle: newGroupChatTitle})
}, [accountTooNewPromptControl, canCreateGroups, 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 (
<NewGroupChatButton
key={item.key}
onPress={handlePressNewGroupChat}
dimmed={!canCreateGroups}
/>
)
}
case 'label': {
return <UserLabel key={item.key} message={item.message} />
}
case 'profile': {
switch (chatState) {
case ChatState.NEW_CHAT:
return (
<DefaultProfileCard
key={item.key}
profile={item.profile}
moderationOpts={moderationOpts!}
onPress={onSelectChat}
/>
)
case ChatState.NEW_GROUP_CHAT:
return (
<GroupChatProfileCard
key={item.key}
profile={item.profile}
moderationOpts={moderationOpts!}
/>
)
case ChatState.GROUP_NAME:
return (
<GroupChatMemberProfileCard
key={item.key}
profile={item.profile}
moderationOpts={moderationOpts!}
/>
)
}
}
case 'placeholder': {
return <ProfileCardSkeleton key={item.key} />
}
case 'empty': {
return <EmptyMemberList key={item.key} message={item.message} />
}
default:
return null
}
},
[
canCreateGroups,
chatState,
handlePressNewGroupChat,
moderationOpts,
onSelectChat,
],
)
useLayoutEffect(() => {
if (IS_WEB) {
setImmediate(() => {
inputRef?.current?.focus()
})
}
}, [])
const groupNameTooLong = isOverMaxGraphemeCount({
text: groupName,
maxCount: MAX_GROUP_NAME_GRAPHEME_LENGTH,
})
let buttonLabel = l`Continue to group name`
let buttonText = l`Next`
let handleButtonPress = handlePressNext
let showButton =
chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0
let isButtonDisabled = !showButton
switch (chatState) {
case ChatState.GROUP_NAME:
buttonLabel = l`Create group chat`
buttonText = l`Create`
handleButtonPress = handlePressConfirm
showButton = true
isButtonDisabled = groupName === '' || groupNameTooLong
break
}
const showChatProfileTabs =
chatState === ChatState.NEW_GROUP_CHAT && groupChatProfiles.length > 0
const listHeader = useMemo(
() => (
<View onLayout={evt => setHeaderHeight(evt.nativeEvent.layout.height)}>
<View
style={[
a.relative,
web(a.pt_lg),
native(a.pt_4xl),
android({
borderTopLeftRadius: a.rounded_md.borderRadius,
borderTopRightRadius: a.rounded_md.borderRadius,
}),
a.px_lg,
chatState !== ChatState.GROUP_NAME ? a.pb_xs : a.pb_lg,
chatState !== ChatState.GROUP_NAME && a.border_b,
t.atoms.border_contrast_low,
t.atoms.bg,
]}>
<View
style={[
a.flex_row,
a.gap_sm,
a.relative,
a.align_center,
a.justify_between,
]}>
{IS_NATIVE ? (
<Button
label={l`Back`}
size="large"
shape="round"
variant="ghost"
color="secondary"
style={[native([a.absolute, a.z_20])]}
onPress={handlePressBack}>
<ButtonIcon icon={ArrowLeftIcon} size="lg" />
</Button>
) : null}
<Text
style={[
a.flex_grow,
a.z_10,
a.text_lg,
a.font_bold,
a.leading_tight,
t.atoms.text_contrast_high,
a.text_center,
a.px_5xl,
]}>
{screenTitle}
</Text>
{IS_WEB ? (
<Button
label={l`Close`}
size="small"
shape="round"
variant="ghost"
color="secondary"
style={[a.absolute, a.z_20, {right: -4}]}
onPress={() => control.close()}>
<ButtonIcon icon={XIcon} size="md" />
</Button>
) : showButton ? (
<Button
label={buttonLabel}
size="small"
color="primary"
style={[
native([
a.absolute,
a.z_20,
{
right: 8,
},
]),
]}
disabled={isButtonDisabled}
onPress={handleButtonPress}>
<ButtonText>{buttonText}</ButtonText>
</Button>
) : null}
</View>
<View style={[web(a.pt_xs), native(a.pt_md)]}>
{chatState === ChatState.GROUP_NAME ? (
<View
style={[a.w_full, a.relative, web(a.pt_md), native(a.pt_xl)]}>
<TextField.Root isInvalid={groupNameTooLong}>
<TextField.Input
label={l`Group name`}
value={groupName}
returnKeyType="next"
keyboardAppearance={t.scheme}
selectTextOnFocus={IS_NATIVE}
autoFocus={false}
accessibilityRole="text"
clearButtonMode="while-editing"
autoCorrect={false}
autoComplete="off"
autoCapitalize="none"
onChangeText={setGroupName}
onSubmitEditing={
isButtonDisabled ? undefined : handleButtonPress
}
/>
</TextField.Root>
{groupNameTooLong ? (
<Text
style={[
a.text_sm,
a.mt_xs,
a.font_semi_bold,
{color: t.palette.negative_400},
]}>
<Trans>
Group name is too long.{' '}
<Plural
value={MAX_GROUP_NAME_GRAPHEME_LENGTH}
other="The maximum number of characters is #."
/>
</Trans>
</Text>
) : null}
</View>
) : (
<UserSearchInput
inputRef={inputRef}
value={searchText}
onChangeText={text => {
setSearchText(text)
listRef.current?.scrollToOffset({offset: 0, animated: false})
}}
onEscape={control.close}
/>
)}
</View>
</View>
{showChatProfileTabs ? (
<View style={[a.pb_sm, a.pt_md, t.atoms.bg]}>
<ChatProfileTabs
testID="newGroupChatMembers"
profiles={groupChatProfiles}
onRemove={onRemoveDid}
/>
</View>
) : null}
</View>
),
[
chatState,
t.atoms.border_contrast_low,
t.atoms.bg,
t.atoms.text_contrast_high,
t.scheme,
l,
handlePressBack,
screenTitle,
showButton,
buttonLabel,
isButtonDisabled,
handleButtonPress,
buttonText,
groupName,
groupNameTooLong,
t.palette.negative_400,
searchText,
control,
showChatProfileTabs,
groupChatProfiles,
onRemoveDid,
],
)
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 (
<Toggle.Group
values={groupChatDids}
onChange={setGroupChatMembers}
type="checkbox"
maxSelections={
// groupMemberLimit counts the creator, who is added implicitly, so
// reserve one slot for them
groupMemberLimit ? groupMemberLimit - 1 : undefined
}
label={
chatState === ChatState.NEW_GROUP_CHAT
? l`Select group chat members`
: l`Start chat`
}
style={web([a.contents])}>
<Prompt.Basic
control={accountTooNewPromptControl}
title={l`Your account is too new`}
description={l`Your account must be at least 7 days old to create a new group chat.`}
confirmButtonCta={l`Okay`}
onConfirm={() => {}}
showCancel={false}
/>
<Dialog.InnerFlatList
ref={listRef}
data={items}
renderItem={renderItems}
ListHeaderComponent={listHeader}
stickyHeaderIndices={[0]}
keyExtractor={(item: Item) => 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 && chatState !== ChatState.NEW_CHAT ? (
<Dialog.FlatListFooter
onLayout={evt => setFooterHeight(evt.nativeEvent.layout.height)}>
<View style={[a.flex_row, a.align_center, a.justify_between]}>
<Button
label={l`Back`}
size="small"
color="secondary"
onPress={handlePressBack}>
<ButtonIcon icon={ArrowLeftIcon} size="md" />
<ButtonText>
{' '}
<Trans>Back</Trans>
</ButtonText>
</Button>
<Button
label={buttonLabel}
size="small"
color="primary"
disabled={isButtonDisabled}
onPress={handleButtonPress}>
<ButtonText>{buttonText} </ButtonText>
{chatState !== ChatState.GROUP_NAME ? (
<ButtonIcon icon={ArrowRightIcon} size="md" />
) : null}
</Button>
</View>
</Dialog.FlatListFooter>
) : null
}
/>
</Toggle.Group>
)
}
function NewGroupChatButton({
onPress,
dimmed = false,
}: {
onPress: () => void
dimmed?: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
return (
<Button
label={l({
message: 'New group chat',
context: 'action',
comment: 'Button used to create a new group chat.',
})}
onPress={onPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.px_lg,
a.py_md,
a.flex_row,
a.flex_1,
a.justify_between,
a.align_center,
a.gap_sm,
pressed || focused || hovered ? t.atoms.bg_contrast_25 : t.atoms.bg,
dimmed && {opacity: 0.5},
]}>
<View
style={[
a.rounded_full,
a.justify_center,
a.align_center,
{
backgroundColor: t.palette.contrast_50,
padding: 12,
},
]}>
<PersonGroupIcon size="md" fill={t.palette.contrast_1000} />
</View>
<View style={[a.flex_grow]}>
<Text
style={[a.text_md, a.font_medium, a.leading_snug, t.atoms.text]}>
<Trans
context="action"
comment="Button used to create a new group chat.">
New group chat
</Trans>
</Text>
</View>
<ChevronRightIcon size="md" fill={t.palette.contrast_1000} />
</View>
)}
</Button>
)
}
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 = sanitizeDisplayName(
profile.displayName || sanitizeHandle(profile.handle),
moderation.ui('displayName'),
)
const handleOnPress = useCallback(() => {
onPress(profile.did)
}, [onPress, profile.did])
return (
<Button
disabled={!enabled}
label={l`Start chat with ${displayName}`}
onPress={handleOnPress}>
{({hovered, pressed, focused}) => (
<View
style={[
a.flex_1,
a.py_sm,
a.px_lg,
!enabled
? {opacity: 0.5}
: pressed || focused || hovered
? t.atoms.bg_contrast_25
: t.atoms.bg,
]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View style={[a.flex_1]}>
<ProfileCard.Name
profile={profile}
moderationOpts={moderationOpts}
/>
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be messaged</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
)}
</Button>
)
}
function GroupChatMemberProfileCard({
profile,
moderationOpts,
}: {
profile: bsky.profile.AnyProfileView
moderationOpts: ModerationOpts
}) {
const t = useTheme()
const enabled = canBeAddedToGroup(profile)
const handle = sanitizeHandle(profile.handle, '@')
return (
<View style={[a.flex_1, a.py_sm, a.px_lg, t.atoms.bg]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
size={44}
disabledPreview
/>
<View style={[a.flex_1]}>
<ProfileCard.Name profile={profile} moderationOpts={moderationOpts} />
{enabled ? (
<ProfileCard.Handle profile={profile} />
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>{handle} cant be added</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
)
}