Files
bsky-social-app/src/components/dms/InitiateChatFlow.tsx
T
Samuel Newman 300a50b69a migrate runtime call sites to lex clients and sdk actions
Phase 3 tasks 3-7 (parallel wave): composer/post pipeline on
pdsClient/appviewClient with structural+instance blob guards and a golden
CID fixture test; chat Convo/EventBus/queries on the dedicated chat client;
preferences sugar to SDK actions on the PDS client; remaining state/queries
producers (usePostThread unspecced flip, video scoped-token clients,
notifications, starter packs, lists) to client.call; UI runtime sweep
(AtUri from @atproto/syntax, moderation fns from @bsky.app/sdk/moderation,
SDK RichText, ozone reason tokens, guard rewrites via #/types/bsky).

Intermediate checkpoint (hooks skipped): ~114 typecheck errors remain in
cross-boundary consumer files, resolved by the type-only codemod next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:12:10 +03:00

1168 lines
33 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 '@bsky.app/sdk/moderation'
import {Plural, Trans, useLingui} from '@lingui/react/macro'
import {MAX_GROUP_NAME_GRAPHEME_LENGTH} from '#/lib/constants'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
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 {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 {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {ChatProfileTabs} from '#/components/dms/ChatProfileTabs'
import {EmptyMemberList} from '#/components/dms/components/EmptyMemberList'
import {GroupChatProfileCard} from '#/components/dms/components/GroupChatProfileCard'
import {ProfileCardSkeleton} from '#/components/dms/components/ProfileCardSkeleton'
import {UserLabel} from '#/components/dms/components/UserLabel'
import {UserSearchInput} from '#/components/dms/components/UserSearchInput'
import {
canBeAddedToGroup,
canBeMessaged,
type ConvoWithDetails,
parseConvoView,
} 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 {ProfileBadges} from '#/components/ProfileBadges'
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 {toLex} from '#/types/bsky'
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 ExistingChatItem = {
type: 'existingChat'
key: string
convo: ConvoWithDetails
}
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
| ExistingChatItem
| 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
searchText: 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
}
| {
type: 'setSearchText'
searchText: 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,
searchText: '',
}
}
case 'removeDids': {
return {
...state,
groupChatDids: action.groupChatDids,
groupChatProfiles: action.groupChatProfiles,
}
}
case 'startNameGroup': {
return {
...state,
chatState: ChatState.GROUP_NAME,
screenTitle: action.screenTitle,
searchText: '',
}
}
case 'nameGroup': {
return {
...state,
groupName: action.groupName,
}
}
case 'goBackFromNewGroupChat': {
return {
...state,
chatState: ChatState.NEW_CHAT,
screenTitle: action.screenTitle,
groupChatDids: [],
groupChatProfiles: [],
groupName: '',
searchText: '',
}
}
case 'goBackFromGroupName': {
return {
...state,
chatState: ChatState.NEW_GROUP_CHAT,
screenTitle: action.screenTitle,
groupName: '',
}
}
case 'setSearchText': {
return {
...state,
searchText: action.searchText,
}
}
}
}
export function InitiateChatFlow({
title,
onSelectChat,
onSelectGroupChat,
startInGroupChat = false,
showRecentConvos = false,
onSelectExistingChat,
sortByMessageDeclaration = false,
}: {
title: string
onSelectChat: (did: string) => void
onSelectGroupChat: (dids: string[], groupName: string) => void
startInGroupChat?: boolean
showRecentConvos?: boolean
onSelectExistingChat?: (convoId: string) => void
sortByMessageDeclaration?: boolean
}) {
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: convos} = useListConvosQuery({
enabled: showRecentConvos,
status: 'accepted',
lockStatus: 'unlocked',
})
const {data: chatStatus} = useChatActorStatusQuery()
const canCreateGroups = chatStatus?.canCreateGroups ?? true
const groupMemberLimit = chatStatus?.groupMemberLimit
const [
{
chatState,
screenTitle,
groupChatDids,
groupChatProfiles,
groupName,
searchText,
},
dispatch,
] = useReducer(reducer, {
chatState: startInGroupChat ? ChatState.NEW_GROUP_CHAT : ChatState.NEW_CHAT,
screenTitle: startInGroupChat ? l`New group chat` : title,
groupChatDids: [],
groupChatProfiles: [],
groupName: '',
searchText: '',
})
const {
data: results,
isError,
isFetching,
} = useActorAutocompleteQuery(searchText, true, 12)
const {data: follows} = useProfileFollowsQuery(currentAccount?.did)
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
const messageDeclarationRank = (item: Item) =>
item.type === 'profile' && checker(item.profile) ? 0 : 1
const compareByMessageDeclaration = (a: Item, b: Item) =>
messageDeclarationRank(a) - messageDeclarationRank(b)
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,
})
}
if (sortByMessageDeclaration) {
_items = _items.sort(compareByMessageDeclaration)
}
}
} else {
const placeholders: Item[] = Array(10)
.fill(0)
.map((__, i) => ({
type: 'placeholder',
key: i + '',
}))
if (
chatState === ChatState.NEW_CHAT &&
showRecentConvos &&
convos &&
follows
) {
const usedDids = new Set()
for (const page of convos.pages) {
for (const convoView of page.convos) {
const convo = parseConvoView(convoView, currentAccount?.did)
if (!convo) continue
if (convo.kind === 'group') {
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
} else {
if (convo.primaryMember.handle === 'missing.invalid') continue
if (usedDids.has(convo.primaryMember.did)) continue
usedDids.add(convo.primaryMember.did)
_items.push({
type: 'existingChat',
key: convo.view.id,
convo,
})
}
}
}
let followsItems: ProfileItem[] = []
for (const page of follows.pages) {
for (const profile of page.follows) {
if (usedDids.has(profile.did)) continue
if (!checker(profile)) continue
followsItems.push({
type: 'profile',
key: profile.did,
profile,
})
}
}
_items.push(...followsItems)
} else 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'})
}
const profileDids = new Set<string>()
return _items.filter(item => {
if (item.type !== 'profile') return true
if (profileDids.has(item.profile.did)) return false
profileDids.add(item.profile.did)
return true
})
}, [
isError,
chatState,
convos,
searchText,
l,
groupChatProfiles,
results,
currentAccount?.did,
follows,
aa.flags.groupChatDisabled,
showRecentConvos,
sortByMessageDeclaration,
])
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})
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})
}, [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 'existingChat': {
return showRecentConvos && onSelectExistingChat ? (
<ExistingChatCard
key={item.key}
convo={item.convo}
moderationOpts={moderationOpts!}
onPress={onSelectExistingChat}
/>
) : null
}
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,
onSelectExistingChat,
showRecentConvos,
],
)
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 => {
dispatch({type: 'setSearchText', searchText: 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 ExistingChatCard({
convo,
moderationOpts,
onPress,
}: {
convo: ConvoWithDetails
moderationOpts: ModerationOpts
onPress: (convoId: string) => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const enabled =
convo.kind === 'group' ? convo.details.lockStatus === 'unlocked' : true
const name =
convo.kind === 'group'
? convo.details.name
: createSanitizedDisplayName(
convo.primaryMember,
true,
moderateProfile(convo.primaryMember, moderationOpts).ui(
'displayName',
),
)
const handleOnPress = useCallback(() => {
onPress(convo.view.id)
}, [onPress, convo.view.id])
return (
<Button
disabled={!enabled}
label={l`Select chat "${name}"`}
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>
{convo.kind === 'group' ? (
<AvatarBubbles profiles={convo.members} size={40} />
) : (
<ProfileCard.Avatar
profile={convo.primaryMember}
moderationOpts={moderationOpts}
disabledPreview
/>
)}
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center, a.max_w_full]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.leading_snug,
a.self_start,
a.flex_shrink,
]}
numberOfLines={1}>
{name}
</Text>
{convo.kind === 'direct' && (
<ProfileBadges
profile={convo.primaryMember}
size="md"
style={[a.pl_xs]}
/>
)}
</View>
{convo.kind === 'direct' ? (
<ProfileCard.Handle profile={convo.primaryMember} />
) : (
<>
{enabled ? (
<Text
style={[a.leading_snug, t.atoms.text_contrast_medium]}
numberOfLines={2}>
<Plural
value={convo.details.memberCount}
one="# member"
other="# members"
/>
</Text>
) : (
<Text
style={[a.leading_snug, t.atoms.text_contrast_high]}
numberOfLines={2}>
<Trans>Group is locked</Trans>
</Text>
)}
</>
)}
</View>
</ProfileCard.Header>
</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)
// TODO(phase4): drop toLex once profile prop emits #/lexicons views
const moderation = moderateProfile(toLex(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} can’t 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} can’t be added</Trans>
</Text>
)}
</View>
</ProfileCard.Header>
</View>
)
}