Support groups in ChatListItem/MessageListHeader (#10229)

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