add initial group support to chatlistitem

This commit is contained in:
Samuel Newman
2026-04-10 21:58:58 +03:00
parent bc2ca60537
commit 7ba739190d
2 changed files with 250 additions and 122 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}
+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}