Support group chats across other surfaces (#10266)
This commit is contained in:
@@ -18,7 +18,7 @@ import type * as bsky from '#/types/bsky'
|
||||
type Props = {
|
||||
animate?: boolean
|
||||
profiles: bsky.profile.AnyProfileView[]
|
||||
size?: 'small' | 'medium' | 'large'
|
||||
size?: 'small' | 'medium' | 'large' | number
|
||||
}
|
||||
|
||||
export function AvatarBubbles({
|
||||
@@ -28,8 +28,22 @@ export function AvatarBubbles({
|
||||
}: Props) {
|
||||
const {currentAccount} = useSession()
|
||||
const profiles = allProfiles.filter(p => p.did !== currentAccount?.did)
|
||||
const containerSize = size === 'small' ? 40 : size === 'medium' ? 56 : 120
|
||||
const scale = size === 'small' ? 40 / 120 : size === 'medium' ? 56 / 120 : 1
|
||||
const containerSize =
|
||||
typeof size === 'number'
|
||||
? size
|
||||
: size === 'small'
|
||||
? 40
|
||||
: size === 'medium'
|
||||
? 56
|
||||
: 120
|
||||
const scale =
|
||||
typeof size === 'number'
|
||||
? size / 120
|
||||
: size === 'small'
|
||||
? 40 / 120
|
||||
: size === 'medium'
|
||||
? 56 / 120
|
||||
: 1
|
||||
const marginOffset = size === 'small' || size === 'medium' ? -2 : 0
|
||||
|
||||
const initialValue = animate ? 0 : 1
|
||||
|
||||
@@ -6,21 +6,21 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {useNavigation} from '@react-navigation/native'
|
||||
|
||||
import {isBlockedOrBlocking, isMuted} from '#/lib/moderation/blocked-and-muted'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {type NavigationProp} from '#/lib/routes/types'
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useProfileShadow} from '#/state/cache/profile-shadow'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useListConvosQuery} from '#/state/queries/messages/list-conversations'
|
||||
import {useSession} from '#/state/session'
|
||||
import {UserAvatar} from '#/view/com/util/UserAvatar'
|
||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
||||
import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {Button} from '#/components/Button'
|
||||
import {useDialogContext} from '#/components/Dialog'
|
||||
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
|
||||
import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export function RecentChats({
|
||||
postUri,
|
||||
@@ -60,23 +60,24 @@ export function RecentChats({
|
||||
showsHorizontalScrollIndicator={false}
|
||||
nestedScrollEnabled>
|
||||
{convos && convos.length > 0 ? (
|
||||
convos.map(convo => {
|
||||
const otherMember = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
convos.map(c => {
|
||||
const convo = parseConvoView(c, currentAccount?.did)
|
||||
|
||||
if (!convo) return null
|
||||
|
||||
if (
|
||||
!otherMember ||
|
||||
otherMember.handle === 'missing.invalid' ||
|
||||
convo.muted
|
||||
)
|
||||
(convo.kind === 'direct' &&
|
||||
convo.primaryMember.handle === 'missing.invalid') ||
|
||||
convo.view.muted
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<RecentChatItem
|
||||
key={convo.id}
|
||||
profile={otherMember}
|
||||
onPress={() => onSelectChat(convo.id)}
|
||||
key={convo.view.id}
|
||||
convo={convo}
|
||||
onPress={() => onSelectChat(convo.view.id)}
|
||||
moderationOpts={moderationOpts}
|
||||
/>
|
||||
)
|
||||
@@ -99,26 +100,33 @@ export function RecentChats({
|
||||
const WIDTH = 80
|
||||
|
||||
function RecentChatItem({
|
||||
profile: profileUnshadowed,
|
||||
onPress,
|
||||
moderationOpts,
|
||||
convo,
|
||||
}: {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
onPress: () => void
|
||||
moderationOpts: ModerationOpts
|
||||
convo: ConvoWithDetails
|
||||
}) {
|
||||
const {_} = useLingui()
|
||||
const t = useTheme()
|
||||
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const primaryProfile = useProfileShadow(convo.primaryMember)
|
||||
|
||||
const moderation = moderateProfile(profile, moderationOpts)
|
||||
const name = sanitizeDisplayName(
|
||||
profile.displayName || sanitizeHandle(profile.handle),
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
const moderation = moderateProfile(primaryProfile, moderationOpts)
|
||||
const name =
|
||||
convo.kind === 'group'
|
||||
? convo.details.name
|
||||
: createSanitizedDisplayName(
|
||||
primaryProfile,
|
||||
true,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
if (isBlockedOrBlocking(profile) || isMuted(profile)) {
|
||||
if (
|
||||
convo.kind === 'direct' &&
|
||||
(isBlockedOrBlocking(primaryProfile) || isMuted(primaryProfile))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -133,12 +141,16 @@ function RecentChatItem({
|
||||
a.justify_start,
|
||||
a.align_center,
|
||||
]}>
|
||||
<UserAvatar
|
||||
avatar={profile.avatar}
|
||||
size={WIDTH - 8}
|
||||
type={profile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
{convo.kind === 'group' ? (
|
||||
<AvatarBubbles profiles={convo.members} size={WIDTH - 8} />
|
||||
) : (
|
||||
<UserAvatar
|
||||
avatar={primaryProfile.avatar}
|
||||
size={WIDTH - 8}
|
||||
type={primaryProfile.associated?.labeler ? 'labeler' : 'user'}
|
||||
moderation={moderation.ui('avatar')}
|
||||
/>
|
||||
)}
|
||||
<View style={[a.flex_row, a.align_center, a.justify_center, a.w_full]}>
|
||||
<Text
|
||||
emoji
|
||||
@@ -146,7 +158,13 @@ function RecentChatItem({
|
||||
numberOfLines={1}>
|
||||
{name}
|
||||
</Text>
|
||||
<ProfileBadges profile={profile} size="xs" style={[a.pl_2xs]} />
|
||||
{convo.kind === 'direct' && (
|
||||
<ProfileBadges
|
||||
profile={primaryProfile}
|
||||
size="xs"
|
||||
style={[a.pl_2xs]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</Button>
|
||||
)
|
||||
|
||||
@@ -8,11 +8,9 @@ import {
|
||||
} from 'react'
|
||||
import {TextInput, View} from 'react-native'
|
||||
import {moderateProfile, type ModerationOpts} from '@atproto/api'
|
||||
import {msg} from '@lingui/core/macro'
|
||||
import {useLingui} from '@lingui/react'
|
||||
import {Trans} from '@lingui/react/macro'
|
||||
import {Plural, Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {sanitizeDisplayName} from '#/lib/strings/display-names'
|
||||
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
|
||||
import {sanitizeHandle} from '#/lib/strings/handles'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
|
||||
@@ -23,7 +21,11 @@ import {type ListMethods} from '#/view/com/util/List'
|
||||
import {android, atoms as a, native, useTheme, web} from '#/alf'
|
||||
import {Button, ButtonIcon} from '#/components/Button'
|
||||
import * as Dialog from '#/components/Dialog'
|
||||
import {canBeMessaged} from '#/components/dms/util'
|
||||
import {
|
||||
canBeMessaged,
|
||||
type ConvoWithDetails,
|
||||
parseConvoView,
|
||||
} from '#/components/dms/util'
|
||||
import {useInteractionState} from '#/components/hooks/useInteractionState'
|
||||
import {MagnifyingGlass_Stroke2_Corner0_Rounded as Search} from '#/components/icons/MagnifyingGlass'
|
||||
import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
|
||||
@@ -31,6 +33,9 @@ import * as ProfileCard from '#/components/ProfileCard'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {IS_WEB} from '#/env'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {AvatarBubbles} from '../AvatarBubbles'
|
||||
import {Error} from '../Error'
|
||||
import {ProfileBadges} from '../ProfileBadges'
|
||||
|
||||
export type ProfileItem = {
|
||||
type: 'profile'
|
||||
@@ -38,6 +43,12 @@ export type ProfileItem = {
|
||||
profile: bsky.profile.AnyProfileView
|
||||
}
|
||||
|
||||
type ExistingChatItem = {
|
||||
type: 'existingChat'
|
||||
key: string
|
||||
convo: ConvoWithDetails
|
||||
}
|
||||
|
||||
type EmptyItem = {
|
||||
type: 'empty'
|
||||
key: string
|
||||
@@ -54,7 +65,12 @@ type ErrorItem = {
|
||||
key: string
|
||||
}
|
||||
|
||||
type Item = ProfileItem | EmptyItem | PlaceholderItem | ErrorItem
|
||||
type Item =
|
||||
| ProfileItem
|
||||
| ExistingChatItem
|
||||
| EmptyItem
|
||||
| PlaceholderItem
|
||||
| ErrorItem
|
||||
|
||||
export function SearchablePeopleList({
|
||||
title,
|
||||
@@ -72,12 +88,14 @@ export function SearchablePeopleList({
|
||||
onSelectChat?: undefined
|
||||
}
|
||||
| {
|
||||
onSelectChat: (did: string) => void
|
||||
onSelectChat: (
|
||||
chat: {kind: 'user'; did: string} | {kind: 'convo'; id: string},
|
||||
) => void
|
||||
renderProfileCard?: undefined
|
||||
}
|
||||
)) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const moderationOpts = useModerationOpts()
|
||||
const control = Dialog.useDialogContext()
|
||||
const [headerHeight, setHeaderHeight] = useState(0)
|
||||
@@ -105,7 +123,7 @@ export function SearchablePeopleList({
|
||||
_items.push({
|
||||
type: 'empty',
|
||||
key: 'empty',
|
||||
message: _(msg`We're having network issues, try again`),
|
||||
message: l`We're having network issues, try again`,
|
||||
})
|
||||
} else if (searchText.length) {
|
||||
if (results?.length) {
|
||||
@@ -139,20 +157,27 @@ export function SearchablePeopleList({
|
||||
const usedDids = new Set()
|
||||
|
||||
for (const page of convos.pages) {
|
||||
for (const convo of page.convos) {
|
||||
const profiles = convo.members.filter(
|
||||
m => m.did !== currentAccount?.did,
|
||||
)
|
||||
for (const convoView of page.convos) {
|
||||
const convo = parseConvoView(convoView, currentAccount?.did)
|
||||
|
||||
for (const profile of profiles) {
|
||||
if (usedDids.has(profile.did)) continue
|
||||
if (!convo) continue
|
||||
|
||||
usedDids.add(profile.did)
|
||||
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: 'profile',
|
||||
key: profile.did,
|
||||
profile,
|
||||
type: 'existingChat',
|
||||
key: convo.view.id,
|
||||
convo: convo,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -209,7 +234,7 @@ export function SearchablePeopleList({
|
||||
|
||||
return _items
|
||||
}, [
|
||||
_,
|
||||
l,
|
||||
searchText,
|
||||
results,
|
||||
isError,
|
||||
@@ -221,12 +246,27 @@ export function SearchablePeopleList({
|
||||
])
|
||||
|
||||
if (searchText && !isFetching && !items.length && !isError) {
|
||||
items.push({type: 'empty', key: 'empty', message: _(msg`No results`)})
|
||||
items.push({type: 'empty', key: 'empty', message: l`No results`})
|
||||
}
|
||||
|
||||
const renderItems = useCallback(
|
||||
({item}: {item: Item}) => {
|
||||
switch (item.type) {
|
||||
case 'existingChat': {
|
||||
if (renderProfileCard) {
|
||||
// should be unreachable
|
||||
return null
|
||||
} else {
|
||||
return (
|
||||
<ExistingChatCard
|
||||
key={item.key}
|
||||
convo={item.convo}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={id => onSelectChat({kind: 'convo', id})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
case 'profile': {
|
||||
if (renderProfileCard) {
|
||||
return <Fragment key={item.key}>{renderProfileCard(item)}</Fragment>
|
||||
@@ -236,7 +276,7 @@ export function SearchablePeopleList({
|
||||
key={item.key}
|
||||
profile={item.profile}
|
||||
moderationOpts={moderationOpts!}
|
||||
onPress={onSelectChat}
|
||||
onPress={did => onSelectChat({kind: 'user', did})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -247,11 +287,14 @@ export function SearchablePeopleList({
|
||||
case 'empty': {
|
||||
return <Empty key={item.key} message={item.message} />
|
||||
}
|
||||
case 'error': {
|
||||
return <Error key={item.key} message={l`Failed to load profiles`} />
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
},
|
||||
[moderationOpts, onSelectChat, renderProfileCard],
|
||||
[moderationOpts, onSelectChat, renderProfileCard, l],
|
||||
)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -293,7 +336,7 @@ export function SearchablePeopleList({
|
||||
</Text>
|
||||
{IS_WEB ? (
|
||||
<Button
|
||||
label={_(msg`Close`)}
|
||||
label={l`Close`}
|
||||
size="small"
|
||||
shape="round"
|
||||
variant={IS_WEB ? 'ghost' : 'solid'}
|
||||
@@ -328,7 +371,7 @@ export function SearchablePeopleList({
|
||||
t.atoms.border_contrast_low,
|
||||
t.atoms.bg,
|
||||
t.atoms.text_contrast_high,
|
||||
_,
|
||||
l,
|
||||
title,
|
||||
searchText,
|
||||
control,
|
||||
@@ -364,12 +407,13 @@ function DefaultProfileCard({
|
||||
onPress: (did: string) => void
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
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),
|
||||
const displayName = createSanitizedDisplayName(
|
||||
profile,
|
||||
true,
|
||||
moderation.ui('displayName'),
|
||||
)
|
||||
|
||||
@@ -380,7 +424,7 @@ function DefaultProfileCard({
|
||||
return (
|
||||
<Button
|
||||
disabled={!enabled}
|
||||
label={_(msg`Start chat with ${displayName}`)}
|
||||
label={l`Start chat with ${displayName}`}
|
||||
onPress={handleOnPress}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
@@ -422,6 +466,113 @@ function DefaultProfileCard({
|
||||
)
|
||||
}
|
||||
|
||||
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 moderation = moderateProfile(convo.primaryMember, moderationOpts)
|
||||
const name =
|
||||
convo.kind === 'group'
|
||||
? convo.details.name
|
||||
: createSanitizedDisplayName(
|
||||
convo.primaryMember,
|
||||
true,
|
||||
moderation.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="small" />
|
||||
) : (
|
||||
<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.members.length}
|
||||
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 ProfileCardSkeleton() {
|
||||
const t = useTheme()
|
||||
|
||||
@@ -488,7 +639,7 @@ function SearchInput({
|
||||
inputRef: React.RefObject<TextInput | null>
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const {_} = useLingui()
|
||||
const {t: l} = useLingui()
|
||||
const {
|
||||
state: hovered,
|
||||
onIn: onMouseEnter,
|
||||
@@ -512,7 +663,7 @@ function SearchInput({
|
||||
<TextInput
|
||||
// @ts-ignore bottom sheet input types issue — esb
|
||||
ref={inputRef}
|
||||
placeholder={_(msg`Search`)}
|
||||
placeholder={l`Search`}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
onFocus={onFocus}
|
||||
@@ -532,8 +683,8 @@ function SearchInput({
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoFocus
|
||||
accessibilityLabel={_(msg`Search profiles`)}
|
||||
accessibilityHint={_(msg`Searches for profiles`)}
|
||||
accessibilityLabel={l`Search profiles`}
|
||||
accessibilityHint={l`Searches for profiles`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
|
||||
@@ -77,6 +77,15 @@ export function NewChat({
|
||||
[control, createGroupChat],
|
||||
)
|
||||
|
||||
const onSelectExistingChat = useCallback(
|
||||
(chatId: string) => {
|
||||
control.close(() => {
|
||||
onNewChat(chatId)
|
||||
})
|
||||
},
|
||||
[control, onNewChat],
|
||||
)
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
control.open()
|
||||
}, [control])
|
||||
@@ -112,7 +121,13 @@ export function NewChat({
|
||||
) : (
|
||||
<SearchablePeopleList
|
||||
title={l`Start a new chat`}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectChat={chat => {
|
||||
if (chat.kind === 'user') {
|
||||
onCreateChat(chat.did)
|
||||
} else {
|
||||
onSelectExistingChat(chat.id)
|
||||
}
|
||||
}}
|
||||
sortByMessageDeclaration
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -53,6 +53,13 @@ function SendViaChatDialogInner({
|
||||
},
|
||||
})
|
||||
|
||||
const onSelectExistingChat = useCallback(
|
||||
(chatId: string) => {
|
||||
control.close(() => onSelectChat(chatId))
|
||||
},
|
||||
[control, onSelectChat],
|
||||
)
|
||||
|
||||
const onCreateChat = useCallback(
|
||||
(did: string) => {
|
||||
control.close(() => createChat([did]))
|
||||
@@ -63,7 +70,13 @@ function SendViaChatDialogInner({
|
||||
return (
|
||||
<SearchablePeopleList
|
||||
title={_(msg`Send post to...`)}
|
||||
onSelectChat={onCreateChat}
|
||||
onSelectChat={chat => {
|
||||
if (chat.kind === 'user') {
|
||||
onCreateChat(chat.did)
|
||||
} else {
|
||||
onSelectExistingChat(chat.id)
|
||||
}
|
||||
}}
|
||||
showRecentConvos
|
||||
sortByMessageDeclaration
|
||||
/>
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export {BottomSheetTextInput as TextInput} from '@discord/bottom-sheet/src'
|
||||
@@ -1 +0,0 @@
|
||||
export {TextInput} from 'react-native'
|
||||
@@ -1,7 +1,8 @@
|
||||
import {type ChatBskyConvoDefs} from '@atproto/api'
|
||||
import {type $Typed, ChatBskyActorDefs, ChatBskyConvoDefs} from '@atproto/api'
|
||||
|
||||
import {EMOJI_REACTION_LIMIT} from '#/lib/constants'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
import {logger} from '#/logger'
|
||||
import * as bsky from '#/types/bsky'
|
||||
|
||||
export function canBeMessaged(profile: bsky.profile.AnyProfileView) {
|
||||
switch (profile.associated?.chat?.allowIncoming) {
|
||||
@@ -54,3 +55,99 @@ export function hasReachedReactionLimit(
|
||||
)
|
||||
return myReactions.length >= EMOJI_REACTION_LIMIT
|
||||
}
|
||||
|
||||
type GroupConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
|
||||
// can be missing if account deleted
|
||||
kind?: $Typed<ChatBskyActorDefs.GroupConvoMember>
|
||||
}
|
||||
|
||||
type DirectConvoMember = ChatBskyActorDefs.ProfileViewBasic & {
|
||||
kind: $Typed<ChatBskyActorDefs.DirectConvoMember>
|
||||
}
|
||||
|
||||
export type ConvoWithDetails = {view: ChatBskyConvoDefs.ConvoView} & (
|
||||
| {
|
||||
kind: 'group'
|
||||
details: ChatBskyConvoDefs.GroupConvo
|
||||
primaryMember: GroupConvoMember // the owner
|
||||
members: Array<GroupConvoMember>
|
||||
}
|
||||
| {
|
||||
kind: 'direct'
|
||||
details: ChatBskyConvoDefs.DirectConvo
|
||||
primaryMember: DirectConvoMember // the other user
|
||||
members: Array<DirectConvoMember>
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts a raw convoView into something easier to use (i.e. extracts chat owner)
|
||||
* and enforces the correct type for convo members.
|
||||
*/
|
||||
export function parseConvoView(
|
||||
convoView: ChatBskyConvoDefs.ConvoView,
|
||||
ownDid: string | undefined,
|
||||
): ConvoWithDetails | null {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
||||
convoView.kind,
|
||||
ChatBskyConvoDefs.isGroupConvo,
|
||||
)
|
||||
) {
|
||||
let owner: GroupConvoMember | undefined = undefined
|
||||
|
||||
for (const member of convoView.members) {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
||||
member.kind,
|
||||
ChatBskyActorDefs.isGroupConvoMember,
|
||||
)
|
||||
) {
|
||||
if (member.kind.role === 'owner') {
|
||||
// have to do a type assertion here
|
||||
// this works: {...member, kind: member.kind}
|
||||
// however that's creating a new object for no good reason
|
||||
owner = member as GroupConvoMember
|
||||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!owner) {
|
||||
throw new Error('No owner found in group convo')
|
||||
}
|
||||
|
||||
return {
|
||||
view: convoView,
|
||||
kind: 'group',
|
||||
details: convoView.kind,
|
||||
primaryMember: owner,
|
||||
members: convoView.members as Array<GroupConvoMember>,
|
||||
}
|
||||
} else if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
|
||||
convoView.kind,
|
||||
ChatBskyConvoDefs.isDirectConvo,
|
||||
)
|
||||
) {
|
||||
const otherUser = convoView.members.find(m => m.did !== ownDid)
|
||||
|
||||
if (!otherUser) {
|
||||
throw new Error('No other user found in direct convo')
|
||||
}
|
||||
|
||||
return {
|
||||
view: convoView,
|
||||
kind: 'direct',
|
||||
details: convoView.kind,
|
||||
primaryMember: otherUser as DirectConvoMember,
|
||||
members: convoView.members as Array<DirectConvoMember>,
|
||||
}
|
||||
} else {
|
||||
logger.warn('Unknown convo kind: ' + JSON.stringify(convoView.kind))
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {useCallback, useMemo, useState} from 'react'
|
||||
import {type GestureResponderEvent, View} from 'react-native'
|
||||
import {
|
||||
AppBskyEmbedRecord,
|
||||
ChatBskyActorDefs,
|
||||
ChatBskyConvoDefs,
|
||||
moderateProfile,
|
||||
type ModerationDecision,
|
||||
@@ -38,6 +37,7 @@ import {AvatarBubbles} from '#/components/AvatarBubbles'
|
||||
import {useDialogControl} from '#/components/Dialog'
|
||||
import {ConvoMenu} from '#/components/dms/ConvoMenu'
|
||||
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
|
||||
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
|
||||
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
|
||||
import {Envelope_Open_Stroke2_Corner0_Rounded as EnvelopeOpen} from '#/components/icons/EnveopeOpen'
|
||||
import {Trash_Stroke2_Corner0_Rounded} from '#/components/icons/Trash'
|
||||
@@ -49,7 +49,7 @@ import {ProfileBadges} from '#/components/ProfileBadges'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {IS_NATIVE} from '#/env'
|
||||
import * as bsky from '#/types/bsky'
|
||||
import type * as bsky from '#/types/bsky'
|
||||
|
||||
export const ChatListItemPortal = createPortalGroup()
|
||||
|
||||
@@ -60,7 +60,7 @@ export const ChatListItemPortal = createPortalGroup()
|
||||
*/
|
||||
|
||||
export function ChatListItem({
|
||||
convo,
|
||||
convo: convoView,
|
||||
showMenu = true,
|
||||
children,
|
||||
}: {
|
||||
@@ -75,83 +75,47 @@ export function ChatListItem({
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvo>(
|
||||
convo.kind,
|
||||
ChatBskyConvoDefs.isGroupConvo,
|
||||
)
|
||||
) {
|
||||
const owner = convo.members.find(r => {
|
||||
if (
|
||||
bsky.dangerousIsType<ChatBskyActorDefs.GroupConvoMember>(
|
||||
r.kind,
|
||||
ChatBskyActorDefs.isGroupConvoMember,
|
||||
)
|
||||
) {
|
||||
return r.kind.role === 'owner'
|
||||
} else {
|
||||
throw new Error(
|
||||
'Expected a GroupConvoMember, got an unknown kind of member',
|
||||
)
|
||||
}
|
||||
})
|
||||
if (!owner) {
|
||||
// TODO: Determine if this is the right thing to do here. Throwing here so that
|
||||
// if it turns out to be wrong it'll be very visible
|
||||
throw new Error('Could not find the group owner in the group members')
|
||||
const convo = parseConvoView(convoView, currentAccount?.did)
|
||||
|
||||
switch (convo?.kind) {
|
||||
case 'direct': {
|
||||
return (
|
||||
<DirectChatItem
|
||||
convo={convo}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}>
|
||||
{children}
|
||||
</DirectChatItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupChatItem
|
||||
convo={convo}
|
||||
groupOwner={owner}
|
||||
groupInfo={convo.kind}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}
|
||||
/>
|
||||
)
|
||||
} else if (
|
||||
bsky.dangerousIsType<ChatBskyConvoDefs.DirectConvo>(
|
||||
convo.kind,
|
||||
ChatBskyConvoDefs.isDirectConvo,
|
||||
)
|
||||
) {
|
||||
const otherMember = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
|
||||
if (!otherMember) {
|
||||
case 'group': {
|
||||
return (
|
||||
<GroupChatItem
|
||||
convo={convo}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<DirectChatItem
|
||||
convo={convo}
|
||||
profile={otherMember}
|
||||
moderationOpts={moderationOpts}
|
||||
showMenu={showMenu}>
|
||||
{children}
|
||||
</DirectChatItem>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function DirectChatItem({
|
||||
convo,
|
||||
profile: profileUnshadowed,
|
||||
moderationOpts,
|
||||
showMenu,
|
||||
children,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
profile: bsky.profile.AnyProfileView
|
||||
convo: Extract<ConvoWithDetails, {kind: 'direct'}>
|
||||
moderationOpts: ModerationOpts
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const profile = useProfileShadow(profileUnshadowed)
|
||||
const profile = useProfileShadow(convo.primaryMember)
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(profile, moderationOpts),
|
||||
@@ -165,7 +129,7 @@ function DirectChatItem({
|
||||
|
||||
return (
|
||||
<BaseChatItem
|
||||
convo={convo}
|
||||
convo={convo.view}
|
||||
avatar={
|
||||
<PreviewableUserAvatar
|
||||
profile={profile}
|
||||
@@ -176,7 +140,9 @@ function DirectChatItem({
|
||||
primaryProfile={profile}
|
||||
primaryProfileModeration={moderation}
|
||||
title={displayName}
|
||||
subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)}
|
||||
subtitle={
|
||||
isDeletedAccount ? undefined : sanitizeHandle(profile.handle, '@')
|
||||
}
|
||||
accessibilityHint={
|
||||
!isDeletedAccount
|
||||
? l`Go to conversation with ${profile.handle}`
|
||||
@@ -200,32 +166,28 @@ function DirectChatItem({
|
||||
|
||||
function GroupChatItem({
|
||||
convo,
|
||||
groupOwner: groupOwnerUnshadowed,
|
||||
groupInfo,
|
||||
moderationOpts,
|
||||
showMenu,
|
||||
children,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
groupOwner: bsky.profile.AnyProfileView
|
||||
groupInfo: ChatBskyConvoDefs.GroupConvo
|
||||
convo: Extract<ConvoWithDetails, {kind: 'group'}>
|
||||
moderationOpts: ModerationOpts
|
||||
showMenu?: boolean
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const {t: l} = useLingui()
|
||||
const groupOwner = useProfileShadow(groupOwnerUnshadowed)
|
||||
const groupOwner = useProfileShadow(convo.primaryMember)
|
||||
|
||||
const moderation = useMemo(
|
||||
() => moderateProfile(groupOwner, moderationOpts),
|
||||
[groupOwner, moderationOpts],
|
||||
)
|
||||
|
||||
const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat`
|
||||
const chatName = convo.details.name
|
||||
|
||||
return (
|
||||
<BaseChatItem
|
||||
convo={convo}
|
||||
convo={convo.view}
|
||||
avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
|
||||
title={chatName}
|
||||
accessibilityHint={l`Go to the group chat named "${chatName}"`}
|
||||
@@ -507,7 +469,7 @@ function BaseChatItem({
|
||||
label={title}
|
||||
accessibilityHint={accessibilityHint}
|
||||
accessibilityActions={
|
||||
IS_NATIVE
|
||||
showMenu && IS_NATIVE
|
||||
? [
|
||||
{
|
||||
name: 'magicTap',
|
||||
@@ -521,8 +483,8 @@ function BaseChatItem({
|
||||
: undefined
|
||||
}
|
||||
onPress={onPress}
|
||||
onLongPress={IS_NATIVE ? onLongPress : undefined}
|
||||
onAccessibilityAction={onLongPress}>
|
||||
onLongPress={showMenu && IS_NATIVE ? onLongPress : undefined}
|
||||
onAccessibilityAction={showMenu ? onLongPress : undefined}>
|
||||
{({hovered, pressed, focused}) => (
|
||||
<View
|
||||
style={[
|
||||
|
||||
@@ -5,31 +5,34 @@ import {Trans} from '@lingui/react/macro'
|
||||
import {useModerationOpts} from '#/state/preferences/moderation-opts'
|
||||
import {useSession} from '#/state/session'
|
||||
import {atoms as a, tokens} from '#/alf'
|
||||
import {parseConvoView} from '#/components/dms/util'
|
||||
import {KnownFollowers} from '#/components/KnownFollowers'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {ChatListItem, ChatListItemPortal} from './ChatListItem'
|
||||
import {AcceptChatButton, DeleteChatButton, RejectMenu} from './RequestButtons'
|
||||
|
||||
export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
export function RequestListItem({
|
||||
convo: convoView,
|
||||
}: {
|
||||
convo: ChatBskyConvoDefs.ConvoView
|
||||
}) {
|
||||
const {currentAccount} = useSession()
|
||||
const moderationOpts = useModerationOpts()
|
||||
|
||||
const otherUser = convo.members.find(
|
||||
member => member.did !== currentAccount?.did,
|
||||
)
|
||||
const convo = parseConvoView(convoView, currentAccount?.did)
|
||||
|
||||
if (!otherUser || !moderationOpts) {
|
||||
if (!convo || !moderationOpts) {
|
||||
return null
|
||||
}
|
||||
|
||||
const isDeletedAccount = otherUser.handle === 'missing.invalid'
|
||||
const isDeletedAccount = convo.primaryMember.handle === 'missing.invalid'
|
||||
|
||||
return (
|
||||
<View style={[a.relative, a.flex_1]}>
|
||||
<ChatListItem convo={convo} showMenu={false}>
|
||||
<ChatListItem convo={convo.view} showMenu={false}>
|
||||
<View style={[a.pt_xs, a.pb_2xs]}>
|
||||
<KnownFollowers
|
||||
profile={otherUser}
|
||||
profile={convo.primaryMember}
|
||||
moderationOpts={moderationOpts}
|
||||
minimal
|
||||
showIfEmpty
|
||||
@@ -59,17 +62,17 @@ export function RequestListItem({convo}: {convo: ChatBskyConvoDefs.ConvoView}) {
|
||||
]}>
|
||||
{!isDeletedAccount ? (
|
||||
<>
|
||||
<AcceptChatButton convo={convo} currentScreen="list" />
|
||||
<AcceptChatButton convo={convo.view} currentScreen="list" />
|
||||
<RejectMenu
|
||||
convo={convo}
|
||||
profile={otherUser}
|
||||
convo={convo.view}
|
||||
profile={convo.primaryMember}
|
||||
showDeleteConvo
|
||||
currentScreen="list"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DeleteChatButton convo={convo} currentScreen="list" />
|
||||
<DeleteChatButton convo={convo.view} currentScreen="list" />
|
||||
<View style={a.flex_1} />
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -24,17 +24,20 @@ export const RQKEY_ROOT = 'convo-list'
|
||||
export const RQKEY = (
|
||||
status: 'accepted' | 'request' | 'all',
|
||||
readState: 'all' | 'unread' = 'all',
|
||||
) => [RQKEY_ROOT, status, readState]
|
||||
kind: 'all' | 'group' | 'direct' = 'all',
|
||||
) => [RQKEY_ROOT, status, readState, kind]
|
||||
type RQPageParam = string | undefined
|
||||
|
||||
export function useListConvosQuery({
|
||||
enabled,
|
||||
status,
|
||||
readState = 'all',
|
||||
kind = 'all',
|
||||
}: {
|
||||
enabled?: boolean
|
||||
status?: 'request' | 'accepted'
|
||||
readState?: 'all' | 'unread'
|
||||
kind?: 'all' | 'group' | 'direct'
|
||||
} = {}) {
|
||||
const agent = useAgent()
|
||||
|
||||
@@ -47,6 +50,7 @@ export function useListConvosQuery({
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
readState: readState === 'unread' ? 'unread' : undefined,
|
||||
kind: kind === 'all' ? undefined : kind,
|
||||
status,
|
||||
},
|
||||
{headers: DM_SERVICE_HEADERS},
|
||||
|
||||
Reference in New Issue
Block a user