Use convo view instead of convo state for driving the UI (#10290)

This commit is contained in:
DS Boyce
2026-04-17 15:23:44 -07:00
committed by GitHub
parent feebc6a98b
commit 935347c73d
3 changed files with 127 additions and 120 deletions
+39 -57
View File
@@ -2,6 +2,7 @@ import {useMemo} from 'react'
import {View} from 'react-native'
import {
type AppBskyActorDefs,
ChatBskyConvoDefs,
type ModerationCause,
type ModerationDecision,
} from '@atproto/api'
@@ -11,14 +12,7 @@ import {useNavigation} from '@react-navigation/native'
import {createSanitizedDisplayName} from '#/lib/moderation/create-sanitized-display-name'
import {makeProfileLink} from '#/lib/routes/links'
import {type NavigationProp} from '#/lib/routes/types'
import {logger} from '#/logger'
import {type Shadow} from '#/state/cache/profile-shadow'
import {
type ActiveConvoStates,
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} from '#/alf'
@@ -32,20 +26,22 @@ import {Link} from '#/components/Link'
import {ProfileBadges} from '#/components/ProfileBadges'
import {Text} from '#/components/Typography'
import {IS_LIQUID_GLASS, IS_WEB} from '#/env'
import {type ConvoWithDetails} from './util'
const PFP_SIZE = IS_WEB ? 40 : Layout.HEADER_SLOT_SIZE
export function MessagesListHeader({
convo,
profile,
moderation,
}: {
convo?: ConvoWithDetails | null
profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation?: ModerationDecision | null
}) {
const t = useTheme()
const convoState = useConvo()
const isGroupChat = convoState?.isGroup?.()
const isGroupChat = convo?.kind === 'group'
const blockInfo = useMemo(() => {
if (!moderation) return
@@ -65,17 +61,17 @@ export function MessagesListHeader({
<View style={[{minHeight: PFP_SIZE}, a.justify_center]}>
<Layout.Header.BackButton />
</View>
{isConvoActive(convoState) ? (
{convo ? (
moderation && blockInfo && profile && !isGroupChat ? (
<ProfileHeaderReady
convoState={convoState}
convo={convo}
profile={profile}
moderation={moderation}
blockInfo={blockInfo}
/>
) : (
<GroupHeaderReady
convoState={convoState}
convo={convo}
profile={profile}
moderation={moderation}
/>
@@ -111,12 +107,12 @@ export function MessagesListHeader({
}
function ProfileHeaderReady({
convoState,
convo,
profile,
moderation,
blockInfo,
}: {
convoState: ActiveConvoStates
convo: ConvoWithDetails
profile: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation: ModerationDecision
blockInfo: {
@@ -132,15 +128,10 @@ function ProfileHeaderReady({
? l`Deleted Account`
: createSanitizedDisplayName(profile, true, moderation.ui('displayName'))
const latestMessageFromOther = convoState.items.findLast(
(item: ConvoItem) =>
item.type === 'message' &&
item.message.sender.did !== currentAccount?.did,
)
const latestReportableMessage =
latestMessageFromOther?.type === 'message'
? latestMessageFromOther.message
ChatBskyConvoDefs.isMessageView(convo.view.lastMessage) &&
convo.view.lastMessage.sender?.did !== currentAccount?.did
? convo.view.lastMessage
: undefined
return (
@@ -164,28 +155,26 @@ function ProfileHeaderReady({
</View>
</Link>
}
muted={convoState.convo?.muted}
muted={convo.view.muted}
settings={
isConvoActive(convoState) ? (
<ConvoMenu
convo={convoState.convo}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
) : null
<ConvoMenu
convo={convo.view}
profile={profile}
currentScreen="conversation"
blockInfo={blockInfo}
latestReportableMessage={latestReportableMessage}
/>
}
/>
)
}
function GroupHeaderReady({
convoState,
convo,
profile,
moderation,
}: {
convoState: ActiveConvoStates
convo: ConvoWithDetails
profile?: Shadow<AppBskyActorDefs.ProfileViewDetailed>
moderation?: ModerationDecision | null
}) {
@@ -193,7 +182,7 @@ function GroupHeaderReady({
const navigation = useNavigation<NavigationProp>()
const groupInfo = convoState.getGroupInfo?.()
const groupInfo = convo.kind === 'group' ? convo.details : undefined
const isDeletedAccount = profile?.handle === 'missing.invalid'
const displayName = isDeletedAccount
@@ -206,40 +195,33 @@ function GroupHeaderReady({
(displayName ? l`${displayName}s group chat` : l`Group chat`)
const handleNavigateToSettings = () => {
const convoId = convoState.convo?.id
if (convoId) {
navigation.navigate('MessagesConversationSettings', {
conversation: convoId,
})
} else {
logger.error(`handleNavigateToSettings: missing convo ID`)
}
navigation.navigate('MessagesConversationSettings', {
conversation: convo.view.id,
})
}
return (
<Wrapper
heading={
<>
<AvatarBubbles size="small" profiles={convoState.recipients ?? []} />
<AvatarBubbles size="small" profiles={convo.members} />
<Text style={[a.text_md, a.font_semi_bold]} numberOfLines={1}>
{groupName}
</Text>
</>
}
muted={convoState.convo?.muted}
muted={convo.view.muted}
settings={
isConvoActive(convoState) ? (
<Button
label={l`Open group chat settings`}
size="small"
color="secondary"
shape="round"
variant="ghost"
style={[a.bg_transparent]}
onPress={handleNavigateToSettings}>
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
) : null
<Button
label={l`Open group chat settings`}
size="small"
color="secondary"
shape="round"
variant="ghost"
style={[a.bg_transparent]}
onPress={handleNavigateToSettings}>
<ButtonIcon icon={DotsHorizontalIcon} size="md" />
</Button>
}
/>
)
+37 -10
View File
@@ -35,6 +35,7 @@ import {ConvoStatus} from '#/state/messages/convo/types'
import {useCurrentConvoId} from '#/state/messages/current-convo-id'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {useSetMinimalShellMode} from '#/state/shell'
import {MessagesList} from '#/screens/Messages/components/MessagesList'
import {atoms as a, useTheme, web} from '#/alf'
@@ -46,6 +47,7 @@ import {
} from '#/components/dialogs/EmailDialog'
import {MessagesListBlockedFooter} from '#/components/dms/MessagesListBlockedFooter'
import {MessagesListHeader} from '#/components/dms/MessagesListHeader'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Error} from '#/components/Error'
import * as Layout from '#/components/Layout'
import {Loader} from '#/components/Loader'
@@ -104,9 +106,14 @@ function Inner() {
const t = useTheme()
const convoState = useConvo()
const {_} = useLingui()
const {currentAccount} = useSession()
const isFocused = useIsFocused()
const {top: topInset} = useSafeAreaInsets()
const convo = convoState.convo
? parseConvoView(convoState.convo, currentAccount?.did)
: null
const moderationOpts = useModerationOpts()
const {data: recipientUnshadowed} = useProfileQuery({
did: convoState.getPrimaryMember?.()?.did,
@@ -144,9 +151,13 @@ function Inner() {
<Layout.Center
style={[a.w_full, IS_LIQUID_GLASS && {paddingTop: topInset}]}>
{moderation ? (
<MessagesListHeader profile={recipient} moderation={moderation} />
<MessagesListHeader
convo={convo}
profile={recipient}
moderation={moderation}
/>
) : (
<MessagesListHeader />
<MessagesListHeader convo={convo} />
)}
</Layout.Center>
<Error
@@ -166,9 +177,13 @@ function Inner() {
{!readyToShow && (
<View style={IS_LIQUID_GLASS && {paddingTop: topInset}}>
{moderation ? (
<MessagesListHeader profile={recipient} moderation={moderation} />
<MessagesListHeader
convo={convo}
profile={recipient}
moderation={moderation}
/>
) : (
<MessagesListHeader />
<MessagesListHeader convo={convo} />
)}
</View>
)}
@@ -178,6 +193,9 @@ function Inner() {
recipient={recipient}
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
convo={convo}
isActive={isConvoActive(convoState)}
hasMessages={isConvoActive(convoState) && convoState.items.length > 0}
/>
{!readyToShow && (
<View
@@ -205,13 +223,18 @@ function InnerReady({
recipient,
hasScrolled,
setHasScrolled,
convo,
isActive,
hasMessages,
}: {
moderation: ModerationDecision | null
recipient: Shadow<AppBskyActorDefs.ProfileViewDetailed> | undefined
hasScrolled: boolean
setHasScrolled: React.Dispatch<React.SetStateAction<boolean>>
convo: ConvoWithDetails | null
isActive: boolean
hasMessages: boolean
}) {
const convoState = useConvo()
const navigation = useNavigation<NavigationProp>()
const {top: topInset} = useSafeAreaInsets()
const [headerHeight, setHeaderHeight] = useState(0)
@@ -262,7 +285,11 @@ function InnerReady({
}, [maybeBlockForEmailVerification])
const header = (
<MessagesListHeader profile={recipient} moderation={moderation} />
<MessagesListHeader
convo={convo}
profile={recipient}
moderation={moderation}
/>
)
return (
@@ -277,7 +304,7 @@ function InnerReady({
) : (
header
)}
{isConvoActive(convoState) && (
{isActive && (
<MessagesList
hasScrolled={hasScrolled}
setHasScrolled={setHasScrolled}
@@ -285,11 +312,11 @@ function InnerReady({
hasAcceptOverride={!!params.accept}
transparentHeaderHeight={IS_LIQUID_GLASS ? headerHeight : 0}
footer={
moderation && recipient ? (
moderation && recipient && convo ? (
<MessagesListBlockedFooter
recipient={recipient}
convoId={convoState.convo.id}
hasMessages={convoState.items.length > 0}
convoId={convo.view.id}
hasMessages={hasMessages}
moderation={moderation}
/>
) : null
+51 -53
View File
@@ -1,6 +1,6 @@
import {useMemo, useState} from 'react'
import {Pressable, type StyleProp, View, type ViewStyle} from 'react-native'
import {type ChatBskyConvoDefs, moderateProfile} from '@atproto/api'
import {moderateProfile} from '@atproto/api'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {StackActions, useNavigation} from '@react-navigation/native'
@@ -34,6 +34,7 @@ import {AvatarBubbles} from '#/components/AvatarBubbles'
import {Button, type ButtonColor, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {AddMembersFlow} from '#/components/dms/AddMembersFlow'
import {type ConvoWithDetails, parseConvoView} from '#/components/dms/util'
import {Error} from '#/components/Error'
import * as TextField from '#/components/forms/TextField'
import {useInteractionState} from '#/components/hooks/useInteractionState'
@@ -126,9 +127,14 @@ function SettingsInner() {
const convoState = useConvo()
const {currentAccount} = useSession()
const primaryMember = convoState?.getPrimaryMember?.()
const data: bsky.profile.AnyProfileView[] = convoState.convo?.members ?? []
const convo = convoState.convo
? parseConvoView(convoState.convo, currentAccount?.did)
: null
const primaryMember = convo?.primaryMember
const isOwner = !!primaryMember && primaryMember.did === currentAccount?.did
const data: bsky.profile.AnyProfileView[] = convo?.members ?? []
const invites: string[] = []
const items = [
@@ -163,11 +169,23 @@ function SettingsInner() {
function renderItem({item}: {item: Item}) {
switch (item.type) {
case 'MEMBERS_AND_REQUESTS':
return <MembersAndRequests memberCount={data.length} requestCount={5} />
return (
<MembersAndRequests
memberCount={data.length}
requestCount={5}
isOwner={isOwner}
/>
)
case 'ADD_MEMBERS_LINK':
return <AddMembersLink />
return <AddMembersLink isOwner={isOwner} />
case 'CHAT_MEMBER':
return <Member profile={item.profile} status={item.status} />
return (
<Member
profile={item.profile}
status={item.status}
isOwner={isOwner}
/>
)
default:
return null
}
@@ -194,8 +212,8 @@ function SettingsInner() {
initialNumToRender={initialNumToRender}
keyExtractor={keyExtractor}
ListHeaderComponent={
convoState.convo ? (
<SettingsHeader convo={convoState.convo} profiles={data} />
convo ? (
<SettingsHeader convo={convo} isOwner={isOwner} />
) : (
<SettingsHeaderPlaceholder />
)
@@ -211,21 +229,15 @@ function SettingsInner() {
function MembersAndRequests({
memberCount,
requestCount,
isOwner,
}: {
memberCount: number
requestCount: number
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const convoState = useConvo()
const {currentAccount} = useSession()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
return (
<View style={[a.flex_row, a.justify_between, a.mx_xl, a.mt_lg, a.mb_sm]}>
<View style={[a.flex_row, a.align_center]}>
@@ -254,20 +266,12 @@ function MembersAndRequests({
)
}
function AddMembersLink() {
function AddMembersLink({isOwner}: {isOwner: boolean}) {
const t = useTheme()
const {t: l} = useLingui()
const convoState = useConvo()
const {currentAccount} = useSession()
const addMembersControl = Dialog.useDialogControl()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
if (!isOwner) {
return null
}
@@ -354,9 +358,11 @@ function AddMembersLink() {
function Member({
profile,
status,
isOwner,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
status: 'owner' | 'member' | 'invited'
isOwner: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const t = useTheme()
@@ -388,7 +394,9 @@ function Member({
break
}
} else {
statusBadge = <MemberMenu profile={profile} type={status} />
statusBadge = (
<MemberMenu profile={profile} type={status} isOwner={isOwner} />
)
}
return (
@@ -496,9 +504,11 @@ function StatusButton({
function MemberMenu({
profile,
type,
isOwner,
}: {
profile: Shadow<bsky.profile.AnyProfileView>
type: 'owner' | 'member' | 'invited'
isOwner: boolean
}) {
const navigation = useNavigation<NavigationProp>()
const t = useTheme()
@@ -506,16 +516,9 @@ function MemberMenu({
const ax = useAnalytics()
const requireEmailVerification = useRequireEmailVerification()
const convoState = useConvo()
const {currentAccount} = useSession()
const blockMemberPrompt = Prompt.usePromptControl()
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
const {data: convoAvailability} = useGetConvoAvailabilityQuery(profile.did)
const {mutate: initiateConvo} = useGetConvoForMembers({
onSuccess: ({convo}) => {
@@ -706,29 +709,22 @@ function MemberMenu({
function SettingsHeader({
convo,
profiles,
isOwner,
}: {
convo: ChatBskyConvoDefs.ConvoView
profiles: bsky.profile.AnyProfileView[]
convo: ConvoWithDetails
isOwner: boolean
}) {
const t = useTheme()
const {t: l} = useLingui()
const navigation = useNavigation<NavigationProp>()
const convoState = useConvo()
const {currentAccount} = useSession()
const groupName = convoState.getGroupInfo?.()?.name ?? ''
const groupName = convo.kind === 'group' ? convo.details.name : ''
const [newGroupName, setNewGroupName] = useState(groupName)
const [isLocked, setIsLocked] = useState(false)
const isOwner =
currentAccount?.did == null
? false
: convoState.getPrimaryMember?.()?.did === currentAccount.did
const {mutate: editGroupName} = useEditGroupName(convo.id, {
const {mutate: editGroupName} = useEditGroupName(convo.view.id, {
onError: e => {
setNewGroupName(groupName)
logger.error('Failed to edit group chat name', {message: e})
@@ -738,7 +734,7 @@ function SettingsHeader({
},
})
const {mutate: muteConvo} = useMuteConvo(convo.id, {
const {mutate: muteConvo} = useMuteConvo(convo.view.id, {
onSuccess: data => {
if (data.convo.muted) {
Toast.show(l({message: 'Group chat muted', context: 'toast'}))
@@ -754,7 +750,7 @@ function SettingsHeader({
},
})
const {mutate: leaveConvo} = useLeaveConvo(convo.id, {
const {mutate: leaveConvo} = useLeaveConvo(convo.view.id, {
onMutate: () => {
navigation.dispatch(StackActions.pop(2))
},
@@ -772,7 +768,7 @@ function SettingsHeader({
const leaveChatPrompt = Prompt.usePromptControl()
const handleToggleMute = () => {
muteConvo({mute: !convo?.muted})
muteConvo({mute: !convo.view.muted})
}
const handleLeaveChat = () => {
@@ -815,7 +811,7 @@ function SettingsHeader({
<View
style={[a.px_xl, a.py_4xl, a.border_b, t.atoms.border_contrast_low]}>
<View style={[a.align_center, a.justify_center]}>
<AvatarBubbles profiles={profiles} />
<AvatarBubbles profiles={convo.members} />
</View>
<Text
style={[
@@ -846,12 +842,14 @@ function SettingsHeader({
a.pt_2xl,
]}>
<SettingsButton
color={convo?.muted ? 'negative_subtle' : 'secondary'}
icon={convo?.muted ? BellOffIcon : BellIcon}
color={convo.view.muted ? 'negative_subtle' : 'secondary'}
icon={convo.view.muted ? BellOffIcon : BellIcon}
label={
convo?.muted ? l`Unmute this group chat` : l`Mute this group chat`
convo.view.muted
? l`Unmute this group chat`
: l`Mute this group chat`
}
text={convo?.muted ? l`Muted` : l`Mute`}
text={convo.view.muted ? l`Muted` : l`Mute`}
onPress={handleToggleMute}
/>
{isOwner ? (