Support groups in ChatListItem/MessageListHeader (#10229)

This commit is contained in:
Samuel Newman
2026-04-10 12:50:31 -07:00
parent c05a12cf89
commit 2390a02a0f
6 changed files with 404 additions and 192 deletions
+6 -6
View File
@@ -1,5 +1,6 @@
import {type StyleProp, View, type ViewStyle} from 'react-native'
import {useSession} from '#/state/session'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme} from '#/alf'
import {Person_Filled_Corner2_Rounded as PersonIcon} from '#/components/icons/Person'
@@ -10,10 +11,9 @@ type Props = {
size?: 'small' | 'medium' | 'large'
}
/**
* TODO This is just layout for now.
*/
export function AvatarBubbles({profiles, size = 'large'}: Props) {
export function AvatarBubbles({profiles: allProfiles, size = 'large'}: 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 marginOffset = size === 'small' || size === 'medium' ? -2 : 0
@@ -21,14 +21,14 @@ export function AvatarBubbles({profiles, size = 'large'}: Props) {
let avatars = (
<>
<AvatarBubble
profile={profiles.length > 0 ? profiles[0] : undefined}
profile={profiles[0] ?? allProfiles[0]}
size={76}
x={-2}
y={-2}
style={[a.z_20]}
/>
<AvatarBubble
profile={profiles.length >= 1 ? profiles[1] : undefined}
profile={profiles[1]}
size={76}
x={42}
y={42}
+81 -67
View File
@@ -5,18 +5,19 @@ import {
type ModerationCause,
type ModerationDecision,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {makeProfileLink} from '#/lib/routes/links'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {type Shadow} from '#/state/cache/profile-shadow'
import {isConvoActive, useConvo} from '#/state/messages/convo'
import {type ConvoItem} from '#/state/messages/convo/types'
import {useSession} from '#/state/session'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useTheme, web} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {Bell2Off_Filled_Corner0_Rounded as BellStroke} from '#/components/icons/Bell2'
import {Bell2Off_Filled_Corner0_Rounded as BellOffIcon} from '#/components/icons/Bell2'
import * as Layout from '#/components/Layout'
import {Link} from '#/components/Link'
import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -34,6 +35,7 @@ export function MessagesListHeader({
moderation?: ModerationDecision
}) {
const t = useTheme()
const convoState = useConvo()
const blockInfo = useMemo(() => {
if (!moderation) return
@@ -78,13 +80,15 @@ export function MessagesListHeader({
a.mt_xs,
]}
/>
<View
style={[
{width: 175, height: 12},
a.rounded_xs,
t.atoms.bg_contrast_25,
]}
/>
{!convoState.isGroup?.() && (
<View
style={[
{width: 175, height: 12},
a.rounded_xs,
t.atoms.bg_contrast_25,
]}
/>
)}
</View>
</View>
@@ -108,22 +112,28 @@ function HeaderReady({
userBlock?: ModerationCause
}
}) {
const {_} = useLingui()
const {t: l} = useLingui()
const t = useTheme()
const convoState = useConvo()
const {currentAccount} = useSession()
const groupInfo = convoState.getGroupInfo?.()
const isGroupChat = groupInfo != null
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const displayName = isGroupChat
? (groupInfo.name ?? l`${profile.handle}'s group chat`)
: isDeletedAccount
? l`Deleted Account`
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
// @ts-ignore findLast is polyfilled - esb
const latestMessageFromOther = convoState.items.findLast(
(item: ConvoItem) =>
item.type === 'message' && item.message.sender.did === profile.did,
item.type === 'message' &&
item.message.sender.did !== currentAccount?.did,
)
const latestReportableMessage =
@@ -134,54 +144,58 @@ function HeaderReady({
return (
<View style={[a.flex_1]}>
<View style={[a.w_full, a.flex_row, a.align_center, a.justify_between]}>
<Link
label={_(msg`View ${displayName}'s profile`)}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[
a.text_md,
a.font_semi_bold,
a.self_start,
web(a.leading_normal),
]}
numberOfLines={1}>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
</View>
{!isDeletedAccount && (
<Text
style={[
t.atoms.text_contrast_medium,
a.text_xs,
web([a.leading_normal, {marginTop: -2}]),
]}
numberOfLines={1}>
@{profile.handle}
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellStroke
size="xs"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
)}
{isGroupChat ? (
<View
style={[a.flex_row, a.align_center, a.gap_md, a.flex_1, a.pr_md]}>
<AvatarBubbles
size="small"
profiles={convoState.recipients ?? []}
/>
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
{displayName}
</Text>
</View>
</Link>
) : (
<Link
label={l`View ${displayName}'s profile`}
style={[a.flex_row, a.align_start, a.gap_md, a.flex_1, a.pr_md]}
to={makeProfileLink(profile)}>
<PreviewableUserAvatar
size={PFP_SIZE}
profile={profile}
moderation={moderation.ui('avatar')}
disableHoverCard={moderation.blocked}
/>
<View style={[a.flex_1]}>
<View style={[a.flex_row, a.align_center]}>
<Text
emoji
style={[a.text_md, a.font_semi_bold, a.self_start]}
numberOfLines={1}>
{displayName}
</Text>
<ProfileBadges profile={profile} size="md" style={[a.pl_xs]} />
</View>
{!isDeletedAccount && (
<Text
style={[t.atoms.text_contrast_medium, a.text_xs]}
numberOfLines={1}>
@{profile.handle}
{convoState.convo?.muted && (
<>
{' '}
&middot;{' '}
<BellOffIcon
size="xs"
style={t.atoms.text_contrast_medium}
/>
</>
)}
</Text>
)}
</View>
</Link>
)}
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.Slot>
+3 -3
View File
@@ -101,7 +101,7 @@ function Inner() {
const moderationOpts = useModerationOpts()
const {data: recipientUnshadowed} = useProfileQuery({
did: convoState.recipients?.[0].did,
did: convoState.getPrimaryMember?.()?.did,
})
const recipient = useMaybeProfileShadow(recipientUnshadowed)
@@ -135,7 +135,7 @@ function Inner() {
<>
<Layout.Center style={[a.flex_1]}>
{moderation ? (
<MessagesListHeader moderation={moderation} profile={recipient} />
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
)}
@@ -156,7 +156,7 @@ function Inner() {
{isFocused && IS_WEB && <RemoveScrollBar />}
{!readyToShow &&
(moderation ? (
<MessagesListHeader moderation={moderation} profile={recipient} />
<MessagesListHeader profile={recipient} moderation={moderation} />
) : (
<MessagesListHeader />
))}
+244 -116
View File
@@ -1,36 +1,39 @@
import {memo, useCallback, useMemo, useState} from 'react'
import {useCallback, useMemo, useState} from 'react'
import {type GestureResponderEvent, View} from 'react-native'
import {
AppBskyEmbedRecord,
ChatBskyConvoDefs,
moderateProfile,
type ModerationDecision,
type ModerationOpts,
} from '@atproto/api'
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {useLingui} from '@lingui/react/macro'
import {useQueryClient} from '@tanstack/react-query'
import {GestureActionView} from '#/lib/custom-animations/GestureActionView'
import {useHaptics} from '#/lib/haptics'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {decrementBadgeCount} from '#/lib/notifications/notifications'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {sanitizeHandle} from '#/lib/strings/handles'
import {
postUriToRelativePath,
toBskyAppUrl,
toShortUrl,
} from '#/lib/strings/url-helpers'
import {useProfileShadow} from '#/state/cache/profile-shadow'
import {type Shadow, useProfileShadow} from '#/state/cache/profile-shadow'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {
precacheConvoQuery,
useMarkAsReadMutation,
} from '#/state/queries/messages/conversation'
import {precacheProfile} from '#/state/queries/profile'
import {unstableCacheProfileView} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {TimeElapsed} from '#/view/com/util/TimeElapsed'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import * as tokens from '#/alf/tokens'
import {AvatarBubbles} from '#/components/AvatarBubbles'
import {useDialogControl} from '#/components/Dialog'
import {ConvoMenu} from '#/components/dms/ConvoMenu'
import {LeaveConvoPrompt} from '#/components/dms/LeaveConvoPrompt'
@@ -45,11 +48,17 @@ import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_NATIVE} from '#/env'
import type * as bsky from '#/types/bsky'
import * as bsky from '#/types/bsky'
export const ChatListItemPortal = createPortalGroup()
export let ChatListItem = ({
/**
* IMPORTANT NOTE: THIS IS CURRENTLY JANKY AF AND PROBABLY BROKEN, JUST WANTED TO ADD GROUPCHAT SUPPPORT
*
* TAKE A SECOND PASS PLEASE -sfn
*/
export function ChatListItem({
convo,
showMenu = true,
children,
@@ -57,32 +66,61 @@ export let ChatListItem = ({
convo: ChatBskyConvoDefs.ConvoView
showMenu?: boolean
children?: React.ReactNode
}): React.ReactNode => {
}) {
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const otherUser = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherUser || !moderationOpts) {
if (!moderationOpts) {
return null
}
return (
<ChatListItemReady
convo={convo}
profile={otherUser}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</ChatListItemReady>
)
switch (convo.kind) {
case 'group': {
const groupInfo = convo.kindData
// TODO: members are missing the role property - find out if intentional
// const owner = convo.members.find(member => member.role === 'owner')
const owner = convo.members[0] // owner will always be the first member
if (
!bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvoData>(
groupInfo,
ChatBskyConvoDefs.isGroupConvoData,
) ||
!owner
) {
return null
}
return (
<GroupChatItem
convo={convo}
groupOwner={owner}
groupInfo={groupInfo}
moderationOpts={moderationOpts}
showMenu={showMenu}
/>
)
}
case 'direct': {
const otherMember = convo.members.find(
member => member.did !== currentAccount?.did,
)
if (!otherMember) {
return null
}
return (
<DirectChatItem
convo={convo}
profile={otherMember}
moderationOpts={moderationOpts}
showMenu={showMenu}>
{children}
</DirectChatItem>
)
}
}
}
ChatListItem = memo(ChatListItem)
function ChatListItemReady({
function DirectChatItem({
convo,
profile: profileUnshadowed,
moderationOpts,
@@ -95,25 +133,140 @@ function ChatListItemReady({
showMenu?: boolean
children?: React.ReactNode
}) {
const ax = useAnalytics()
const t = useTheme()
const {_} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {gtMobile} = useBreakpoints()
const {t: l} = useLingui()
const profile = useProfileShadow(profileUnshadowed)
const {mutate: markAsRead} = useMarkAsReadMutation()
const moderation = useMemo(
() => moderateProfile(profile, moderationOpts),
[profile, moderationOpts],
)
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
return (
<BaseChatItem
convo={convo}
avatar={
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
}
primaryProfile={profile}
primaryProfileModeration={moderation}
title={displayName}
subtitle={isDeletedAccount ? undefined : sanitizeHandle(profile.handle)}
accessibilityHint={
!isDeletedAccount
? l`Go to conversation with ${profile.handle}`
: l`This conversation is with a deleted or a deactivated account. Press for options`
}
showMenu={showMenu}
isDeletedAccount={isDeletedAccount}
isBlockedAccount={moderation.blocked}
showProfileBadges
postAlerts={
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
}>
{children}
</BaseChatItem>
)
}
function GroupChatItem({
convo,
groupOwner: groupOwnerUnshadowed,
groupInfo,
moderationOpts,
showMenu,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
groupOwner: bsky.profile.AnyProfileView
groupInfo: ChatBskyConvoDefs.GroupConvoData
moderationOpts: ModerationOpts
showMenu?: boolean
children?: React.ReactNode
}) {
const {t: l} = useLingui()
const groupOwner = useProfileShadow(groupOwnerUnshadowed)
const moderation = useMemo(
() => moderateProfile(groupOwner, moderationOpts),
[groupOwner, moderationOpts],
)
const chatName = groupInfo.name ?? l`${groupOwner.handle}'s group chat`
return (
<BaseChatItem
convo={convo}
avatar={<AvatarBubbles profiles={convo.members} size="medium" />}
title={chatName}
accessibilityHint={l`Go to the group chat named "${chatName}"`}
primaryProfile={groupOwner}
primaryProfileModeration={moderation}
isBlockedAccount={false}
isDeletedAccount={false}
showProfileBadges={false}
showMenu={showMenu}>
{children}
</BaseChatItem>
)
}
function BaseChatItem({
convo,
avatar,
title,
subtitle,
accessibilityHint,
isDeletedAccount,
isBlockedAccount,
primaryProfile,
primaryProfileModeration,
showMenu,
showProfileBadges,
postAlerts,
children,
}: {
convo: ChatBskyConvoDefs.ConvoView
avatar: React.ReactNode
title: string
subtitle?: string
accessibilityHint: string
isDeletedAccount: boolean
isBlockedAccount: boolean
primaryProfile: Shadow<bsky.profile.AnyProfileView>
primaryProfileModeration: ModerationDecision
showMenu?: boolean
showProfileBadges: boolean
postAlerts?: React.ReactNode
children?: React.ReactNode
}) {
const ax = useAnalytics()
const t = useTheme()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const menuControl = useMenuControl()
const leaveConvoControl = useDialogControl()
const {mutate: markAsRead} = useMarkAsReadMutation()
const {gtMobile} = useBreakpoints()
const playHaptic = useHaptics()
const queryClient = useQueryClient()
const isUnread = convo.unreadCount > 0
const blockInfo = useMemo(() => {
const modui = moderation.ui('profileView')
const modui = primaryProfileModeration.ui('profileView')
const blocks = modui.alerts.filter(alert => alert.type === 'blocking')
const listBlocks = blocks.filter(alert => alert.source.type === 'list')
const userBlock = blocks.find(alert => alert.source.type === 'user')
@@ -121,21 +274,13 @@ function ChatListItemReady({
listBlocks,
userBlock,
}
}, [moderation])
}, [primaryProfileModeration])
const isDeletedAccount = profile.handle === 'missing.invalid'
const displayName = isDeletedAccount
? _(msg`Deleted Account`)
: sanitizeDisplayName(
profile.displayName || profile.handle,
moderation.ui('displayName'),
)
const isDimStyle = convo.muted || moderation.blocked || isDeletedAccount
const isDimStyle = convo.muted || isBlockedAccount || isDeletedAccount
const {lastMessage, lastMessageSentAt, latestReportableMessage} =
useMemo(() => {
let lastMessage = _(msg`No messages yet`)
let lastMessage = l`No messages yet`
let lastMessageSentAt: string | null = null
@@ -150,14 +295,12 @@ function ChatListItemReady({
if (convo.lastMessage.text) {
if (isFromMe) {
lastMessage = _(msg`You: ${convo.lastMessage.text}`)
lastMessage = l`You: ${convo.lastMessage.text}`
} else {
lastMessage = convo.lastMessage.text
}
} else if (convo.lastMessage.embed) {
const defaultEmbeddedContentMessage = _(
msg`(contains embedded content)`,
)
const defaultEmbeddedContentMessage = l`(contains embedded content)`
if (AppBskyEmbedRecord.isView(convo.lastMessage.embed)) {
const embed = convo.lastMessage.embed
@@ -172,14 +315,14 @@ function ChatListItemReady({
? toShortUrl(href)
: defaultEmbeddedContentMessage
if (isFromMe) {
lastMessage = _(msg`You: ${short}`)
lastMessage = l`You: ${short}`
} else {
lastMessage = short
}
}
} else {
if (isFromMe) {
lastMessage = _(msg`You: ${defaultEmbeddedContentMessage}`)
lastMessage = l`You: ${defaultEmbeddedContentMessage}`
} else {
lastMessage = defaultEmbeddedContentMessage
}
@@ -192,8 +335,8 @@ function ChatListItemReady({
lastMessageSentAt = convo.lastMessage.sentAt
lastMessage = isDeletedAccount
? _(msg`Conversation deleted`)
: _(msg`Message deleted`)
? l`Conversation deleted`
: l`Message deleted`
}
if (ChatBskyConvoDefs.isMessageAndReactionView(convo.lastReaction)) {
@@ -205,44 +348,36 @@ function ChatListItemReady({
const isFromMe =
convo.lastReaction.reaction.sender.did === currentAccount?.did
const lastMessageText = convo.lastReaction.message.text
const fallbackMessage = _(
msg({
message: 'a message',
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
}),
)
const fallbackMessage = l({
message: 'a message',
comment: `If last message does not contain text, fall back to "{user} reacted to {a message}"`,
})
if (isFromMe) {
lastMessage = _(
msg`You reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`You reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
const senderDid = convo.lastReaction.reaction.sender.did
const sender = convo.members.find(
member => member.did === senderDid,
)
if (sender) {
lastMessage = _(
msg`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`${sanitizeDisplayName(
sender.displayName || sender.handle,
)} reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
} else {
lastMessage = _(
msg`Someone reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`,
)
lastMessage = l`Someone reacted ${convo.lastReaction.reaction.value} to ${
lastMessageText
? `"${convo.lastReaction.message.text}"`
: fallbackMessage
}`
}
}
}
@@ -254,7 +389,7 @@ function ChatListItemReady({
latestReportableMessage,
}
}, [
_,
l,
convo.lastMessage,
convo.lastReaction,
currentAccount?.did,
@@ -279,9 +414,11 @@ function ChatListItemReady({
const onPress = useCallback(
(e: GestureResponderEvent) => {
precacheProfile(queryClient, profile)
for (const member of convo.members) {
unstableCacheProfileView(queryClient, member)
}
precacheConvoQuery(queryClient, convo)
decrementBadgeCount(convo.unreadCount)
void decrementBadgeCount(convo.unreadCount)
if (isDeletedAccount) {
e.preventDefault()
menuControl.open()
@@ -290,7 +427,7 @@ function ChatListItemReady({
ax.metric('chat:open', {logContext: 'ChatsList'})
}
},
[ax, isDeletedAccount, menuControl, queryClient, profile, convo],
[ax, isDeletedAccount, menuControl, queryClient, convo],
)
const onLongPress = useCallback(() => {
@@ -345,33 +482,23 @@ function ChatListItemReady({
a.absolute,
{top: tokens.space.md, left: tokens.space.lg},
]}>
<PreviewableUserAvatar
profile={profile}
size={52}
moderation={moderation.ui('avatar')}
/>
{avatar}
</View>
<Link
to={`/messages/${convo.id}`}
label={displayName}
accessibilityHint={
!isDeletedAccount
? _(msg`Go to conversation with ${profile.handle}`)
: _(
msg`This conversation is with a deleted or a deactivated account. Press for options`,
)
}
label={title}
accessibilityHint={accessibilityHint}
accessibilityActions={
IS_NATIVE
? [
{
name: 'magicTap',
label: _(msg`Open conversation options`),
label: l`Open conversation options`,
},
{
name: 'longpress',
label: _(msg`Open conversation options`),
label: l`Open conversation options`,
},
]
: undefined
@@ -407,14 +534,18 @@ function ChatListItemReady({
{lineHeight: 21},
isDimStyle && t.atoms.text_contrast_medium,
]}>
{displayName}
{title}
</Text>
</View>
<ProfileBadges
profile={profile}
size="md"
style={[a.pl_xs, a.self_center]}
/>
{showProfileBadges && (
<ProfileBadges
profile={primaryProfile}
size="md"
style={[a.pl_xs, a.self_center]}
/>
)}
{lastMessageSentAt && (
<View style={[a.pl_xs]}>
<TimeElapsed timestamp={lastMessageSentAt}>
@@ -432,7 +563,7 @@ function ChatListItemReady({
</TimeElapsed>
</View>
)}
{(convo.muted || moderation.blocked) && (
{(convo.muted || isBlockedAccount) && (
<Text
style={[
a.text_sm,
@@ -450,7 +581,7 @@ function ChatListItemReady({
)}
</View>
{!isDeletedAccount && (
{subtitle && (
<Text
numberOfLines={1}
style={[
@@ -458,7 +589,7 @@ function ChatListItemReady({
t.atoms.text_contrast_medium,
a.pb_xs,
]}>
@{profile.handle}
{subtitle}
</Text>
)}
@@ -474,11 +605,7 @@ function ChatListItemReady({
{lastMessage}
</Text>
<PostAlerts
modui={moderation.ui('contentList')}
size="lg"
style={[a.pt_xs]}
/>
{postAlerts}
{children}
</View>
@@ -509,7 +636,7 @@ function ChatListItemReady({
{showMenu && (
<ConvoMenu
convo={convo}
profile={profile}
profile={primaryProfile}
control={menuControl}
currentScreen="list"
showMarkAsRead={convo.unreadCount > 0}
@@ -529,6 +656,7 @@ function ChatListItemReady({
latestReportableMessage={latestReportableMessage}
/>
)}
<LeaveConvoPrompt
control={leaveConvoControl}
convoId={convo.id}
+46
View File
@@ -37,6 +37,7 @@ import {
import {type MessagesEventBus} from '#/state/messages/events/agent'
import {type MessagesEventBusError} from '#/state/messages/events/types'
import {IS_NATIVE} from '#/env'
import * as bsky from '#/types/bsky'
const logger = Logger.create(Logger.Context.ConversationAgent)
@@ -112,6 +113,9 @@ export class Convo {
this.markConvoAccepted = this.markConvoAccepted.bind(this)
this.addReaction = this.addReaction.bind(this)
this.removeReaction = this.removeReaction.bind(this)
this.isGroup = this.isGroup.bind(this)
this.getGroupInfo = this.getGroupInfo.bind(this)
this.getPrimaryMember = this.getPrimaryMember.bind(this)
}
private commit() {
@@ -155,6 +159,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Disabled:
@@ -175,6 +182,9 @@ export class Convo {
markConvoAccepted: this.markConvoAccepted,
addReaction: this.addReaction,
removeReaction: this.removeReaction,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
case ConvoStatus.Error: {
@@ -192,6 +202,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: undefined,
getGroupInfo: undefined,
getPrimaryMember: undefined,
}
}
default: {
@@ -209,6 +222,9 @@ export class Convo {
markConvoAccepted: undefined,
addReaction: undefined,
removeReaction: undefined,
isGroup: this.isGroup,
getGroupInfo: this.getGroupInfo,
getPrimaryMember: this.getPrimaryMember,
}
}
}
@@ -1332,4 +1348,34 @@ export class Convo {
throw error
}
}
// Group utilities
isGroup(): boolean | undefined {
if (!this.convo) return undefined
return this.convo.kind === 'group'
}
getGroupInfo(): ChatBskyConvoDefs.GroupConvoData | undefined {
if (
this.convo &&
bsky.dangerousIsType<ChatBskyConvoDefs.GroupConvoData>(
this.convo.kindData,
ChatBskyConvoDefs.isGroupConvoData,
)
) {
return this.convo.kindData
}
return undefined
}
getPrimaryMember(): ChatBskyActorDefs.ProfileViewBasic | undefined {
if (this.convo?.kind === 'group') {
return (
this.recipients?.find(r => r.role === 'owner') ?? this.recipients?.[0]
)
} else {
return this.recipients?.find(r => r.did !== this.senderUserDid)
}
}
}
+24
View File
@@ -144,6 +144,9 @@ type FetchMessageHistory = () => Promise<void>
type MarkConvoAccepted = () => void
type AddReaction = (messageId: string, reaction: string) => Promise<void>
type RemoveReaction = (messageId: string, reaction: string) => Promise<void>
type IsGroup = () => boolean | undefined
type GetGroupInfo = () => ChatBskyConvoDefs.GroupConvoData | undefined
type GetPrimaryMember = () => ChatBskyActorDefs.ProfileViewBasic | undefined
export type ConvoStateUninitialized = {
status: ConvoStatus.Uninitialized
@@ -159,6 +162,9 @@ export type ConvoStateUninitialized = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateInitializing = {
status: ConvoStatus.Initializing
@@ -174,6 +180,9 @@ export type ConvoStateInitializing = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateReady = {
status: ConvoStatus.Ready
@@ -189,6 +198,9 @@ export type ConvoStateReady = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateBackgrounded = {
status: ConvoStatus.Backgrounded
@@ -204,6 +216,9 @@ export type ConvoStateBackgrounded = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateSuspended = {
status: ConvoStatus.Suspended
@@ -219,6 +234,9 @@ export type ConvoStateSuspended = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoStateError = {
status: ConvoStatus.Error
@@ -234,6 +252,9 @@ export type ConvoStateError = {
markConvoAccepted: undefined
addReaction: undefined
removeReaction: undefined
isGroup: undefined
getGroupInfo: undefined
getPrimaryMember: undefined
}
export type ConvoStateDisabled = {
status: ConvoStatus.Disabled
@@ -249,6 +270,9 @@ export type ConvoStateDisabled = {
markConvoAccepted: MarkConvoAccepted
addReaction: AddReaction
removeReaction: RemoveReaction
isGroup: IsGroup
getGroupInfo: GetGroupInfo
getPrimaryMember: GetPrimaryMember
}
export type ConvoState =
| ConvoStateUninitialized